diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-03 14:00:04 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-03 14:00:04 +0100 |
| commit | 7366cb27e98d9e0b15e0176ac7ca32816c460284 (patch) | |
| tree | 07635628d37c06489519f7daf336c7f3f4fd3a2a /apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts | |
| parent | 34daad9df7ed6dac4fbeba8619ca1958bb2dac86 (diff) | |
| download | opencut-7366cb27e98d9e0b15e0176ac7ca32816c460284.tar.gz | |
fix v1 to v2 migration
Diffstat (limited to 'apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts')
| -rw-r--r-- | apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts | 464 |
1 files changed, 458 insertions, 6 deletions
diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index a6aaa195..b4ed5a9f 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -4,13 +4,68 @@ import { DEFAULT_COLOR, DEFAULT_FPS, } from "@/constants/project-constants"; +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; +import type { MediaAssetData } from "@/services/storage/types"; +import type { + AudioElement, + ImageElement, + TextElement, + TimelineTrack, + Transform, + VideoElement, +} from "@/types/timeline"; import type { MigrationResult, ProjectRecord } from "./types"; -export function transformProjectV1ToV2({ +interface LegacyTimelineData { + tracks: unknown[]; + lastModified: string; +} + +interface LegacyMediaElement { + type: "media"; + mediaId: string; + muted?: boolean; + [id: string]: unknown; +} + +interface LegacyTextElement { + type: "text"; + x: number; + y: number; + rotation: number; + opacity: number; + [id: string]: unknown; +} + +interface LegacyAudioElement { + type: "audio"; + mediaId: string; + [id: string]: unknown; +} + +interface LegacyMediaTrack { + type: "media"; + elements: unknown[]; + [id: string]: unknown; +} + +export interface TransformV1ToV2Options { + loadMediaAsset?: ({ + projectId, + mediaId, + }: { + projectId: string; + mediaId: string; + }) => Promise<MediaAssetData | null>; +} + +export async function transformProjectV1ToV2({ project, + options = {}, }: { project: ProjectRecord; -}): MigrationResult<ProjectRecord> { + options?: TransformV1ToV2Options; +}): Promise<MigrationResult<ProjectRecord>> { const projectId = getProjectId({ project }); if (!projectId) { return { project, skipped: true, reason: "no project id" }; @@ -20,17 +75,29 @@ export function transformProjectV1ToV2({ return { project, skipped: true, reason: "already v2" }; } - const migratedProject = migrateProject({ project, projectId }); + const migratedProject = await migrateProject({ + project, + projectId, + loadMediaAsset: options.loadMediaAsset, + }); return { project: migratedProject, skipped: false }; } -function migrateProject({ +async function migrateProject({ project, projectId, + loadMediaAsset, }: { project: ProjectRecord; projectId: string; -}): ProjectRecord { + loadMediaAsset?: ({ + projectId, + mediaId, + }: { + projectId: string; + mediaId: string; + }) => Promise<MediaAssetData | null>; +}): Promise<ProjectRecord> { const createdAt = normalizeDateString({ value: project.createdAt }); const updatedAt = normalizeDateString({ value: project.updatedAt }); const metadataValue = project.metadata; @@ -56,8 +123,47 @@ function migrateProject({ const legacyBookmarks = Array.isArray(project.bookmarks) ? project.bookmarks : null; + + const migratedScenes = await Promise.all( + scenes.map(async (scene) => { + if (!isRecord(scene)) { + return scene; + } + + const sceneId = getStringValue({ value: scene.id }); + if (!sceneId) { + return scene; + } + + const existingTracks = scene.tracks; + const shouldLoadTracks = + !Array.isArray(existingTracks) || existingTracks.length === 0; + + if (!shouldLoadTracks) { + return scene; + } + + const tracks = await loadTracksFromLegacyDB({ + projectId, + sceneId, + isMain: scene.isMain === true, + }); + + const transformedTracks = await transformTracks({ + tracks, + projectId, + loadMediaAsset, + }); + + return { + ...scene, + tracks: transformedTracks, + }; + }), + ); + const normalizedScenes = applyLegacyBookmarks({ - scenes, + scenes: migratedScenes, legacyBookmarks, }); @@ -75,6 +181,7 @@ function migrateProject({ background: getBackgroundValue({ value: settingsValue.background, }), + originalCanvasSize: null, } : { fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }), @@ -88,6 +195,7 @@ function migrateProject({ backgroundColor: project.backgroundColor, blurIntensity: project.blurIntensity, }), + originalCanvasSize: null, }; const currentSceneId = getCurrentSceneId({ @@ -105,6 +213,336 @@ function migrateProject({ }; } +async function loadTracksFromLegacyDB({ + projectId, + sceneId, + isMain, +}: { + projectId: string; + sceneId: string; + isMain: boolean; +}): Promise<unknown[]> { + if (typeof indexedDB === "undefined") { + return []; + } + + const sceneDbName = `video-editor-timelines-${projectId}-${sceneId}`; + const projectDbName = `video-editor-timelines-${projectId}`; + + const adapter = new IndexedDBAdapter<LegacyTimelineData>( + sceneDbName, + "timeline", + 1, + ); + + let data = await adapter.get("timeline"); + + if (!data && isMain) { + const projectAdapter = new IndexedDBAdapter<LegacyTimelineData>( + projectDbName, + "timeline", + 1, + ); + data = await projectAdapter.get("timeline"); + } + + if (!data || !Array.isArray(data.tracks)) { + return []; + } + + return data.tracks; +} + +async function transformTracks({ + tracks, + projectId, + loadMediaAsset, +}: { + tracks: unknown[]; + projectId: string; + loadMediaAsset?: ({ + projectId, + mediaId, + }: { + projectId: string; + mediaId: string; + }) => Promise<MediaAssetData | null>; +}): Promise<TimelineTrack[]> { + if (!Array.isArray(tracks)) { + return []; + } + + let isFirstVideoTrackFound = false; + + const transformedTracks = await Promise.all( + tracks.map(async (track) => { + if (!isRecord(track)) { + return null; + } + + const trackType = track.type; + if (trackType === "media") { + const videoTrack = await transformMediaTrack({ + track: track as LegacyMediaTrack, + projectId, + loadMediaAsset, + isMain: !isFirstVideoTrackFound, + }); + isFirstVideoTrackFound = true; + return videoTrack; + } + + if (trackType === "text") { + return transformTextTrack({ track }); + } + + if (trackType === "audio") { + return transformAudioTrack({ track }); + } + + return null; + }), + ); + + return transformedTracks.filter( + (track): track is TimelineTrack => track !== null, + ); +} + +async function transformMediaTrack({ + track, + projectId, + loadMediaAsset, + isMain, +}: { + track: LegacyMediaTrack; + projectId: string; + loadMediaAsset?: ({ + projectId, + mediaId, + }: { + projectId: string; + mediaId: string; + }) => Promise<MediaAssetData | null>; + isMain: boolean; +}): Promise<TimelineTrack> { + const elements = Array.isArray(track.elements) ? track.elements : []; + + const transformedElements = await Promise.all( + elements.map(async (element) => { + if (!isRecord(element) || element.type !== "media") { + return null; + } + + const mediaElement = element as LegacyMediaElement; + const mediaId = getStringValue({ value: mediaElement.mediaId }); + if (!mediaId) { + return null; + } + + let mediaType: "video" | "image" = "video"; + if (loadMediaAsset) { + const mediaAsset = await loadMediaAsset({ projectId, mediaId }); + if (mediaAsset) { + mediaType = mediaAsset.type === "image" ? "image" : "video"; + } + } + + const defaultTransform: Transform = { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; + + const muted = mediaElement.muted === true; + + if (mediaType === "image") { + const imageElement: ImageElement = { + id: getStringValue({ value: element.id, fallback: "" }), + name: getStringValue({ value: element.name, fallback: "" }), + type: "image", + mediaId, + duration: getNumberValue({ value: element.duration, fallback: 0 }), + startTime: getNumberValue({ + value: element.startTime, + fallback: 0, + }), + trimStart: getNumberValue({ + value: element.trimStart, + fallback: 0, + }), + trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), + hidden: false, + transform: defaultTransform, + opacity: 1, + }; + return imageElement; + } + + const videoElement: VideoElement = { + id: getStringValue({ value: element.id, fallback: "" }), + name: getStringValue({ value: element.name, fallback: "" }), + type: "video", + mediaId, + muted, + hidden: false, + transform: defaultTransform, + opacity: 1, + duration: getNumberValue({ value: element.duration, fallback: 0 }), + startTime: getNumberValue({ value: element.startTime, fallback: 0 }), + trimStart: getNumberValue({ value: element.trimStart, fallback: 0 }), + trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), + }; + return videoElement; + }), + ); + + const validElements = transformedElements.filter( + (el): el is VideoElement | ImageElement => el !== null, + ); + + return { + id: getStringValue({ value: track.id, fallback: "" }), + name: getStringValue({ value: track.name, fallback: "" }), + type: "video", + elements: validElements, + isMain, + muted: false, + hidden: false, + }; +} + +function transformTextTrack({ + track, +}: { + track: Record<string, unknown>; +}): TimelineTrack { + const elements = Array.isArray(track.elements) ? track.elements : []; + + const transformedElements = elements + .map((element): TextElement | null => { + if (!isRecord(element) || element.type !== "text") { + return null; + } + + const textElement = element as LegacyTextElement; + const x = getNumberValue({ value: textElement.x, fallback: 0 }); + const y = getNumberValue({ value: textElement.y, fallback: 0 }); + const rotation = getNumberValue({ + value: textElement.rotation, + fallback: 0, + }); + const opacity = getNumberValue({ + value: textElement.opacity, + fallback: 1, + }); + + const transform: Transform = { + scale: 1, + position: { x, y }, + rotate: rotation, + }; + + return { + id: getStringValue({ value: element.id, fallback: "" }), + name: getStringValue({ value: element.name, fallback: "" }), + type: "text", + content: getStringValue({ value: textElement.content, fallback: "" }), + fontSize: getNumberValue({ + value: textElement.fontSize, + fallback: 16, + }), + fontFamily: getStringValue({ + value: textElement.fontFamily, + fallback: "Arial", + }), + color: getStringValue({ + value: textElement.color, + fallback: "#000000", + }), + backgroundColor: getStringValue({ + value: textElement.backgroundColor, + fallback: "#FFFFFF", + }), + textAlign: (getStringValue({ + value: textElement.textAlign, + fallback: "left", + }) || "left") as "left" | "center" | "right", + fontWeight: (getStringValue({ + value: textElement.fontWeight, + fallback: "normal", + }) || "normal") as "normal" | "bold", + fontStyle: (getStringValue({ + value: textElement.fontStyle, + fallback: "normal", + }) || "normal") as "normal" | "italic", + textDecoration: (getStringValue({ + value: textElement.textDecoration, + fallback: "none", + }) || "none") as "none" | "underline" | "line-through", + hidden: false, + transform, + opacity, + duration: getNumberValue({ value: element.duration, fallback: 0 }), + startTime: getNumberValue({ value: element.startTime, fallback: 0 }), + trimStart: getNumberValue({ value: element.trimStart, fallback: 0 }), + trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), + }; + }) + .filter((el): el is TextElement => el !== null); + + return { + id: getStringValue({ value: track.id, fallback: "" }), + name: getStringValue({ value: track.name, fallback: "" }), + type: "text", + elements: transformedElements, + hidden: false, + }; +} + +function transformAudioTrack({ + track, +}: { + track: Record<string, unknown>; +}): TimelineTrack { + const elements = Array.isArray(track.elements) ? track.elements : []; + + const transformedElements = elements + .map((element): AudioElement | null => { + if (!isRecord(element) || element.type !== "audio") { + return null; + } + + const audioElement = element as LegacyAudioElement; + const mediaId = getStringValue({ value: audioElement.mediaId }); + if (!mediaId) { + return null; + } + + return { + id: getStringValue({ value: element.id, fallback: "" }), + name: getStringValue({ value: element.name, fallback: "" }), + type: "audio", + sourceType: "upload", + mediaId, + volume: 1, + duration: getNumberValue({ value: element.duration, fallback: 0 }), + startTime: getNumberValue({ value: element.startTime, fallback: 0 }), + trimStart: getNumberValue({ value: element.trimStart, fallback: 0 }), + trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), + }; + }) + .filter((el): el is AudioElement => el !== null); + + return { + id: getStringValue({ value: track.id, fallback: "" }), + name: getStringValue({ value: track.name, fallback: "" }), + type: "audio", + elements: transformedElements, + muted: false, + }; +} + export function getProjectId({ project, }: { @@ -291,6 +729,20 @@ function getStringValue({ fallback, }: { value: unknown; + fallback: string; +}): string; +function getStringValue({ + value, + fallback, +}: { + value: unknown; + fallback?: undefined; +}): string | undefined; +function getStringValue({ + value, + fallback, +}: { + value: unknown; fallback?: string; }): string | undefined { if (typeof value === "string") { |
