summaryrefslogtreecommitdiff
path: root/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-03 14:00:04 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-03 14:00:04 +0100
commit7366cb27e98d9e0b15e0176ac7ca32816c460284 (patch)
tree07635628d37c06489519f7daf336c7f3f4fd3a2a /apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
parent34daad9df7ed6dac4fbeba8619ca1958bb2dac86 (diff)
downloadopencut-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.ts464
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") {