)}
diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx
index c60c2de5..12bddc8e 100644
--- a/apps/web/src/components/editor/onboarding.tsx
+++ b/apps/web/src/components/editor/onboarding.tsx
@@ -55,8 +55,8 @@ export function Onboarding() {
-
-
+
+
Next
@@ -85,9 +85,7 @@ export function Onboarding() {
{getStepTitle()}
-
- {renderStepContent()}
-
+
{renderStepContent()}
);
diff --git a/apps/web/src/constants/editor-constants.ts b/apps/web/src/constants/editor-constants.ts
index d9570825..213ea875 100644
--- a/apps/web/src/constants/editor-constants.ts
+++ b/apps/web/src/constants/editor-constants.ts
@@ -9,7 +9,7 @@ export const PANEL_CONFIG = {
tools: 25,
preview: 50,
properties: 25,
- mainContent: 70,
- timeline: 30,
+ mainContent: 50,
+ timeline: 50,
},
};
diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts
index 8c043e83..62195669 100644
--- a/apps/web/src/lib/scenes.ts
+++ b/apps/web/src/lib/scenes.ts
@@ -81,7 +81,7 @@ export function getProjectDurationFromScenes({
scenes: TScene[];
}): number {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
- if (!mainScene) {
+ if (!mainScene?.tracks || !Array.isArray(mainScene.tracks)) {
return 0;
}
diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts
new file mode 100644
index 00000000..ee1485ba
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Test fixtures for storage migrations.
+ * These represent real project data shapes from each version.
+ */
+
+export * from "./v0";
+export * from "./v1";
+export * from "./v2";
+export * from "./v3";
diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v0.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v0.ts
new file mode 100644
index 00000000..24428cfe
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v0.ts
@@ -0,0 +1,71 @@
+export const v0Project = {
+ id: "project-v0-123",
+ name: "My V0 Project",
+ createdAt: "2024-01-15T10:00:00.000Z",
+ updatedAt: "2024-01-15T12:00:00.000Z",
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ backgroundColor: "#000000",
+ backgroundType: "color",
+ tracks: [
+ {
+ id: "track-1",
+ type: "video",
+ name: "Video Track",
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ mediaId: "media-1",
+ startTime: 0,
+ duration: 5,
+ trimStart: 0,
+ trimEnd: 0,
+ },
+ ],
+ },
+ ],
+ bookmarks: [1.5, 3.0],
+};
+
+export const v0ProjectWithMetadata = {
+ id: "project-v0-456",
+ metadata: {
+ id: "project-v0-456",
+ name: "V0 With Metadata",
+ createdAt: "2024-02-01T08:00:00.000Z",
+ updatedAt: "2024-02-01T09:00:00.000Z",
+ },
+ fps: 24,
+ canvasSize: { width: 1280, height: 720 },
+ backgroundType: "blur",
+ blurIntensity: 20,
+};
+
+export const v0ProjectEmpty = {
+ id: "project-empty",
+ name: "Empty Project",
+ createdAt: "2024-03-01T00:00:00.000Z",
+ updatedAt: "2024-03-01T00:00:00.000Z",
+};
+
+// Edge cases
+export const projectWithNoId = {
+ name: "No ID Project",
+ version: 1,
+ scenes: [],
+};
+
+export const projectWithNullValues = {
+ id: "project-nulls",
+ version: 1,
+ name: null,
+ metadata: null,
+ scenes: null,
+ settings: null,
+};
+
+export const projectMalformed = {
+ id: "project-malformed",
+ // Missing almost everything
+};
diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v1.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v1.ts
new file mode 100644
index 00000000..93904947
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v1.ts
@@ -0,0 +1,124 @@
+export const v1Project = {
+ id: "project-v1-123",
+ version: 1,
+ name: "My V1 Project",
+ createdAt: "2024-01-15T10:00:00.000Z",
+ updatedAt: "2024-01-15T12:00:00.000Z",
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ backgroundColor: "#1a1a1a",
+ backgroundType: "color",
+ currentSceneId: "scene-main",
+ bookmarks: [2.0, 4.5, 7.0],
+ scenes: [
+ {
+ id: "scene-main",
+ name: "Main scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-1",
+ type: "video",
+ name: "Video Track",
+ isMain: true,
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ mediaId: "media-1",
+ startTime: 0,
+ duration: 10,
+ trimStart: 0,
+ trimEnd: 0,
+ },
+ ],
+ },
+ {
+ id: "track-2",
+ type: "audio",
+ name: "Audio Track",
+ elements: [
+ {
+ id: "element-2",
+ type: "audio",
+ sourceType: "upload",
+ mediaId: "media-2",
+ startTime: 0,
+ duration: 10,
+ trimStart: 0,
+ trimEnd: 0,
+ },
+ ],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-01-15T10:00:00.000Z",
+ updatedAt: "2024-01-15T12:00:00.000Z",
+ },
+ ],
+};
+
+export const v1ProjectWithMultipleScenes = {
+ id: "project-v1-multi",
+ version: 1,
+ metadata: {
+ id: "project-v1-multi",
+ name: "Multi-Scene Project",
+ createdAt: "2024-02-20T14:00:00.000Z",
+ updatedAt: "2024-02-20T16:00:00.000Z",
+ },
+ currentSceneId: "scene-1",
+ fps: 60,
+ canvasSize: { width: 3840, height: 2160 },
+ background: { type: "blur", blurIntensity: 15 },
+ scenes: [
+ {
+ id: "scene-1",
+ name: "Intro",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-1",
+ type: "video",
+ isMain: true,
+ elements: [
+ {
+ id: "el-1",
+ type: "video",
+ mediaId: "m1",
+ startTime: 0,
+ duration: 5,
+ },
+ ],
+ },
+ ],
+ bookmarks: [1.0],
+ createdAt: "2024-02-20T14:00:00.000Z",
+ updatedAt: "2024-02-20T16:00:00.000Z",
+ },
+ {
+ id: "scene-2",
+ name: "Content",
+ isMain: false,
+ tracks: [
+ {
+ id: "track-2",
+ type: "video",
+ isMain: true,
+ elements: [
+ {
+ id: "el-2",
+ type: "video",
+ mediaId: "m2",
+ startTime: 0,
+ duration: 20,
+ },
+ ],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-02-20T14:30:00.000Z",
+ updatedAt: "2024-02-20T16:00:00.000Z",
+ },
+ ],
+};
\ No newline at end of file
diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v2.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v2.ts
new file mode 100644
index 00000000..85ea69f5
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v2.ts
@@ -0,0 +1,147 @@
+export const v2Project = {
+ id: "project-v2-123",
+ version: 2,
+ metadata: {
+ id: "project-v2-123",
+ name: "My V2 Project",
+ thumbnail: "data:image/png;base64,abc123",
+ createdAt: "2024-03-01T10:00:00.000Z",
+ updatedAt: "2024-03-01T14:00:00.000Z",
+ },
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ currentSceneId: "scene-main",
+ scenes: [
+ {
+ id: "scene-main",
+ name: "Main scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-1",
+ type: "video",
+ name: "Video Track",
+ isMain: true,
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ mediaId: "media-1",
+ startTime: 0,
+ duration: 15.5,
+ trimStart: 0,
+ trimEnd: 0,
+ },
+ ],
+ },
+ {
+ id: "track-2",
+ type: "text",
+ name: "Text Track",
+ elements: [
+ {
+ id: "element-2",
+ type: "text",
+ content: "Hello World",
+ startTime: 2,
+ duration: 5,
+ },
+ ],
+ },
+ ],
+ bookmarks: [5.0, 10.0],
+ createdAt: "2024-03-01T10:00:00.000Z",
+ updatedAt: "2024-03-01T14:00:00.000Z",
+ },
+ ],
+};
+
+export const v2ProjectWithBlurBackground = {
+ id: "project-v2-blur",
+ version: 2,
+ metadata: {
+ id: "project-v2-blur",
+ name: "Blur Background Project",
+ createdAt: "2024-03-15T08:00:00.000Z",
+ updatedAt: "2024-03-15T10:00:00.000Z",
+ },
+ settings: {
+ fps: 24,
+ canvasSize: { width: 1080, height: 1920 },
+ background: { type: "blur", blurIntensity: 25 },
+ },
+ currentSceneId: "scene-1",
+ scenes: [
+ {
+ id: "scene-1",
+ name: "Main scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-1",
+ type: "video",
+ isMain: true,
+ elements: [
+ {
+ id: "el-1",
+ type: "video",
+ mediaId: "m1",
+ startTime: 0,
+ duration: 30,
+ },
+ ],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-03-15T08:00:00.000Z",
+ updatedAt: "2024-03-15T10:00:00.000Z",
+ },
+ ],
+};
+
+export const v2ProjectEmptyScenes = {
+ id: "project-v2-empty",
+ version: 2,
+ metadata: {
+ id: "project-v2-empty",
+ name: "Empty Scenes Project",
+ createdAt: "2024-04-01T00:00:00.000Z",
+ updatedAt: "2024-04-01T00:00:00.000Z",
+ },
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#ffffff" },
+ },
+ currentSceneId: "",
+ scenes: [],
+};
+
+export const v2ProjectSceneWithoutTracks = {
+ id: "project-v2-no-tracks",
+ version: 2,
+ metadata: {
+ id: "project-v2-no-tracks",
+ name: "Scene Without Tracks",
+ createdAt: "2024-04-01T00:00:00.000Z",
+ updatedAt: "2024-04-01T00:00:00.000Z",
+ },
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ currentSceneId: "scene-1",
+ scenes: [
+ {
+ id: "scene-1",
+ name: "Main Scene",
+ isMain: true,
+ createdAt: "2024-04-01T00:00:00.000Z",
+ updatedAt: "2024-04-01T00:00:00.000Z",
+ },
+ ],
+};
diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v3.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v3.ts
new file mode 100644
index 00000000..1d46e362
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v3.ts
@@ -0,0 +1,47 @@
+export const v3Project = {
+ id: "project-v3-123",
+ version: 3,
+ metadata: {
+ id: "project-v3-123",
+ name: "My V3 Project",
+ thumbnail: "data:image/png;base64,xyz789",
+ duration: 25.5,
+ createdAt: "2024-05-01T10:00:00.000Z",
+ updatedAt: "2024-05-01T14:00:00.000Z",
+ },
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ currentSceneId: "scene-main",
+ scenes: [
+ {
+ id: "scene-main",
+ name: "Main scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-1",
+ type: "video",
+ name: "Video Track",
+ isMain: true,
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ mediaId: "media-1",
+ startTime: 0,
+ duration: 25.5,
+ trimStart: 0,
+ trimEnd: 0,
+ },
+ ],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-05-01T10:00:00.000Z",
+ updatedAt: "2024-05-01T14:00:00.000Z",
+ },
+ ],
+};
diff --git a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts
new file mode 100644
index 00000000..2cd67da8
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, test } from "bun:test";
+import { getProjectId, transformProjectV0ToV1 } from "../transformers/v0-to-v1";
+import {
+ projectMalformed,
+ projectWithNoId,
+ v0Project,
+ v0ProjectEmpty,
+ v0ProjectWithMetadata,
+ v1Project,
+} from "./fixtures";
+
+describe("V0 to V1 Migration", () => {
+ const fixedDate = new Date("2024-06-01T12:00:00.000Z");
+
+ describe("transformProjectV0ToV1", () => {
+ test("adds scenes array to v0 project", () => {
+ const result = transformProjectV0ToV1({
+ project: v0Project,
+ options: { now: fixedDate },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(1);
+ expect(Array.isArray(result.project.scenes)).toBe(true);
+ expect((result.project.scenes as unknown[]).length).toBe(1);
+ expect(result.project.currentSceneId).toBeDefined();
+ });
+
+ test("creates main scene with correct structure", () => {
+ const result = transformProjectV0ToV1({
+ project: v0Project,
+ options: { now: fixedDate },
+ });
+
+ const scenes = result.project.scenes as Array
>;
+ const mainScene = scenes[0];
+
+ expect(mainScene.isMain).toBe(true);
+ expect(mainScene.name).toBe("Main scene");
+ expect(typeof mainScene.id).toBe("string");
+ expect(Array.isArray(mainScene.tracks)).toBe(true);
+ expect(Array.isArray(mainScene.bookmarks)).toBe(true);
+ });
+
+ test("updates metadata.updatedAt when metadata exists", () => {
+ const result = transformProjectV0ToV1({
+ project: v0ProjectWithMetadata,
+ options: { now: fixedDate },
+ });
+
+ const metadata = result.project.metadata as Record;
+ expect(metadata.updatedAt).toBe(fixedDate.toISOString());
+ });
+
+ test("updates root updatedAt when no metadata", () => {
+ const result = transformProjectV0ToV1({
+ project: v0ProjectEmpty,
+ options: { now: fixedDate },
+ });
+
+ expect(result.project.updatedAt).toBe(fixedDate.toISOString());
+ });
+
+ test("skips project that already has scenes", () => {
+ const result = transformProjectV0ToV1({
+ project: v1Project,
+ options: { now: fixedDate },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already has scenes");
+ });
+
+ test("preserves original project properties", () => {
+ const result = transformProjectV0ToV1({
+ project: v0Project,
+ options: { now: fixedDate },
+ });
+
+ expect(result.project.id).toBe(v0Project.id);
+ expect(result.project.name).toBe(v0Project.name);
+ expect(result.project.fps).toBe(v0Project.fps);
+ expect(result.project.canvasSize).toEqual(v0Project.canvasSize);
+ });
+ });
+
+ describe("getProjectId", () => {
+ test("returns id from root level", () => {
+ const id = getProjectId({ project: v0Project });
+ expect(id).toBe("project-v0-123");
+ });
+
+ test("returns id from metadata when root id missing", () => {
+ const project = {
+ metadata: { id: "from-metadata" },
+ };
+ const id = getProjectId({ project });
+ expect(id).toBe("from-metadata");
+ });
+
+ test("returns null when no id found", () => {
+ const id = getProjectId({ project: projectWithNoId });
+ expect(id).toBe(null);
+ });
+
+ test("returns null for malformed project", () => {
+ const id = getProjectId({ project: projectMalformed });
+ expect(id).toBe("project-malformed");
+ });
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
new file mode 100644
index 00000000..ff6a31c2
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, test } from "bun:test";
+import {
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_CANVAS_SIZE,
+ DEFAULT_COLOR,
+ DEFAULT_FPS,
+} from "@/constants/project-constants";
+import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
+import {
+ projectWithNoId,
+ projectWithNullValues,
+ v1Project,
+ v1ProjectWithMultipleScenes,
+ v2Project,
+} from "./fixtures";
+
+describe("V1 to V2 Migration", () => {
+ describe("transformProjectV1ToV2", () => {
+ test("creates metadata object from flat properties", () => {
+ const result = transformProjectV1ToV2({ project: v1Project });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(2);
+
+ const metadata = result.project.metadata as Record;
+ expect(metadata.id).toBe(v1Project.id);
+ expect(metadata.name).toBe(v1Project.name);
+ expect(typeof metadata.createdAt).toBe("string");
+ expect(typeof metadata.updatedAt).toBe("string");
+ });
+
+ test("creates settings object from flat properties", () => {
+ const result = transformProjectV1ToV2({ project: v1Project });
+
+ const settings = result.project.settings as Record;
+ expect(settings.fps).toBe(v1Project.fps);
+ expect(settings.canvasSize).toEqual(v1Project.canvasSize);
+ });
+
+ test("converts color background correctly", () => {
+ const result = transformProjectV1ToV2({ project: v1Project });
+
+ const settings = result.project.settings as Record;
+ const background = settings.background as Record;
+ expect(background.type).toBe("color");
+ expect(background.color).toBe(v1Project.backgroundColor);
+ });
+
+ test("converts blur background correctly", () => {
+ const projectWithBlur = {
+ ...v1Project,
+ backgroundType: "blur",
+ blurIntensity: 30,
+ };
+ const result = transformProjectV1ToV2({ project: projectWithBlur });
+
+ const settings = result.project.settings as Record;
+ const background = settings.background as Record;
+ expect(background.type).toBe("blur");
+ expect(background.blurIntensity).toBe(30);
+ });
+
+ test("applies legacy bookmarks to main scene", () => {
+ const result = transformProjectV1ToV2({ project: v1Project });
+
+ const scenes = result.project.scenes as Array>;
+ const mainScene = scenes.find((s) => s.isMain === true);
+ expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks);
+ });
+
+ test("preserves existing scene bookmarks", () => {
+ const result = transformProjectV1ToV2({
+ project: v1ProjectWithMultipleScenes,
+ });
+
+ const scenes = result.project.scenes as Array>;
+ const introScene = scenes.find((s) => s.name === "Intro");
+ expect(introScene?.bookmarks).toEqual([1.0]);
+ });
+
+ test("skips project that already has v2 structure", () => {
+ const result = transformProjectV1ToV2({ project: v2Project });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v2");
+ });
+
+ test("skips project with no id", () => {
+ const result = transformProjectV1ToV2({ project: projectWithNoId });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+
+ test("handles null values gracefully", () => {
+ const result = transformProjectV1ToV2({ project: projectWithNullValues });
+
+ expect(result.skipped).toBe(false);
+ const settings = result.project.settings as Record;
+ expect(settings.fps).toBe(DEFAULT_FPS);
+ expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
+ });
+
+ test("uses default values for missing properties", () => {
+ const minimalProject = {
+ id: "minimal",
+ version: 1,
+ scenes: [],
+ };
+ const result = transformProjectV1ToV2({ project: minimalProject });
+
+ const settings = result.project.settings as Record;
+ expect(settings.fps).toBe(DEFAULT_FPS);
+ expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
+
+ const background = settings.background as Record;
+ expect(background.type).toBe("color");
+ expect(background.color).toBe(DEFAULT_COLOR);
+ });
+
+ test("uses default blur intensity when missing", () => {
+ const projectWithBlurNoIntensity = {
+ id: "blur-no-intensity",
+ version: 1,
+ backgroundType: "blur",
+ scenes: [],
+ };
+ const result = transformProjectV1ToV2({
+ project: projectWithBlurNoIntensity,
+ });
+
+ const settings = result.project.settings as Record;
+ const background = settings.background as Record;
+ expect(background.blurIntensity).toBe(DEFAULT_BLUR_INTENSITY);
+ });
+
+ test("preserves currentSceneId", () => {
+ const result = transformProjectV1ToV2({ project: v1Project });
+ expect(result.project.currentSceneId).toBe(v1Project.currentSceneId);
+ });
+
+ test("finds main scene id when currentSceneId missing", () => {
+ const projectWithoutCurrentScene = {
+ ...v1Project,
+ currentSceneId: undefined,
+ };
+ const result = transformProjectV1ToV2({
+ project: projectWithoutCurrentScene,
+ });
+ expect(result.project.currentSceneId).toBe("scene-main");
+ });
+ });
+
+ describe("getProjectId", () => {
+ test("returns id from root level", () => {
+ const id = getProjectId({ project: v1Project });
+ expect(id).toBe("project-v1-123");
+ });
+
+ test("returns id from metadata", () => {
+ const id = getProjectId({ project: v1ProjectWithMultipleScenes });
+ expect(id).toBe("project-v1-multi");
+ });
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts
new file mode 100644
index 00000000..ba8b3bfb
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, test } from "bun:test";
+import { getProjectId, transformProjectV2ToV3 } from "../transformers/v2-to-v3";
+import {
+ projectWithNoId,
+ v2Project,
+ v2ProjectEmptyScenes,
+ v2ProjectSceneWithoutTracks,
+ v2ProjectWithBlurBackground,
+ v3Project,
+} from "./fixtures";
+
+describe("V2 to V3 Migration", () => {
+ describe("transformProjectV2ToV3", () => {
+ test("adds duration to metadata", () => {
+ const result = transformProjectV2ToV3({ project: v2Project });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(3);
+
+ const metadata = result.project.metadata as Record;
+ expect(typeof metadata.duration).toBe("number");
+ });
+
+ test("calculates duration from scene tracks", () => {
+ const result = transformProjectV2ToV3({ project: v2Project });
+
+ const metadata = result.project.metadata as Record;
+ // v2Project has a video element with duration 15.5 and a text element at startTime 2 with duration 5
+ // Total duration should be max(15.5, 2+5) = 15.5
+ expect(metadata.duration).toBe(15.5);
+ });
+
+ test("handles project with blur background", () => {
+ const result = transformProjectV2ToV3({
+ project: v2ProjectWithBlurBackground,
+ });
+
+ expect(result.skipped).toBe(false);
+ const metadata = result.project.metadata as Record;
+ // v2ProjectWithBlurBackground has a video with duration 30
+ expect(metadata.duration).toBe(30);
+ });
+
+ test("handles empty scenes with zero duration", () => {
+ const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes });
+
+ expect(result.skipped).toBe(false);
+ const metadata = result.project.metadata as Record;
+ expect(metadata.duration).toBe(0);
+ });
+
+ test("handles scene without tracks property", () => {
+ const result = transformProjectV2ToV3({
+ project: v2ProjectSceneWithoutTracks,
+ });
+
+ expect(result.skipped).toBe(false);
+ const metadata = result.project.metadata as Record;
+ expect(metadata.duration).toBe(0);
+ });
+
+ test("skips project that already has v3 structure", () => {
+ const result = transformProjectV2ToV3({ project: v3Project });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v3");
+ });
+
+ test("skips project that has duration in metadata", () => {
+ const projectWithDuration = {
+ ...v2Project,
+ version: 2,
+ metadata: {
+ ...v2Project.metadata,
+ duration: 10,
+ },
+ };
+ const result = transformProjectV2ToV3({ project: projectWithDuration });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v3");
+ });
+
+ test("skips project with no id", () => {
+ const result = transformProjectV2ToV3({ project: projectWithNoId });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+
+ test("preserves existing metadata fields", () => {
+ const result = transformProjectV2ToV3({ project: v2Project });
+
+ const metadata = result.project.metadata as Record;
+ expect(metadata.id).toBe(v2Project.metadata.id);
+ expect(metadata.name).toBe(v2Project.metadata.name);
+ expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail);
+ expect(metadata.createdAt).toBe(v2Project.metadata.createdAt);
+ expect(metadata.updatedAt).toBe(v2Project.metadata.updatedAt);
+ });
+
+ test("preserves settings object", () => {
+ const result = transformProjectV2ToV3({ project: v2Project });
+
+ expect(result.project.settings).toEqual(v2Project.settings);
+ });
+
+ test("preserves scenes array", () => {
+ const result = transformProjectV2ToV3({ project: v2Project });
+
+ expect(result.project.scenes).toEqual(v2Project.scenes);
+ });
+
+ test("handles project without metadata object", () => {
+ const projectWithoutMetadata = {
+ id: "no-metadata",
+ version: 2,
+ scenes: [],
+ };
+ const result = transformProjectV2ToV3({
+ project: projectWithoutMetadata,
+ });
+
+ expect(result.skipped).toBe(false);
+ const metadata = result.project.metadata as Record;
+ expect(metadata.duration).toBe(0);
+ });
+
+ test("calculates duration from main scene only", () => {
+ const multiSceneProject = {
+ id: "multi-scene",
+ version: 2,
+ metadata: { id: "multi-scene", name: "Multi" },
+ scenes: [
+ {
+ id: "scene-1",
+ isMain: true,
+ tracks: [
+ {
+ type: "video",
+ elements: [{ startTime: 0, duration: 10 }],
+ },
+ ],
+ },
+ {
+ id: "scene-2",
+ isMain: false,
+ tracks: [
+ {
+ type: "video",
+ elements: [{ startTime: 0, duration: 20 }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = transformProjectV2ToV3({ project: multiSceneProject });
+
+ const metadata = result.project.metadata as Record;
+ // Duration is from main scene only, not sum of all scenes
+ expect(metadata.duration).toBe(10);
+ });
+ });
+
+ describe("getProjectId", () => {
+ test("returns id from root level", () => {
+ const projectWithRootId = { id: "root-id", metadata: {} };
+ const id = getProjectId({ project: projectWithRootId });
+ expect(id).toBe("root-id");
+ });
+
+ test("returns id from metadata when root id missing", () => {
+ const id = getProjectId({ project: v2Project });
+ expect(id).toBe("project-v2-123");
+ });
+
+ test("prefers root id over metadata id", () => {
+ const projectWithBothIds = {
+ id: "root-id",
+ metadata: { id: "metadata-id" },
+ };
+ const id = getProjectId({ project: projectWithBothIds });
+ expect(id).toBe("root-id");
+ });
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts
new file mode 100644
index 00000000..74cf46a9
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/index.ts
@@ -0,0 +1,4 @@
+export { transformProjectV0ToV1 } from "./v0-to-v1";
+export { transformProjectV1ToV2 } from "./v1-to-v2";
+export { transformProjectV2ToV3 } from "./v2-to-v3";
+export type { MigrationResult, ProjectRecord } from "./types";
diff --git a/apps/web/src/services/storage/migrations/transformers/types.ts b/apps/web/src/services/storage/migrations/transformers/types.ts
new file mode 100644
index 00000000..b5ff8a03
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/types.ts
@@ -0,0 +1,13 @@
+/**
+ * Type definitions for different project versions used in migrations.
+ * These types are intentionally loose (using Record) because we're dealing
+ * with potentially malformed data from older versions.
+ */
+
+export type ProjectRecord = Record;
+
+export interface MigrationResult {
+ project: T;
+ skipped: boolean;
+ reason?: string;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts b/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts
new file mode 100644
index 00000000..d2259b64
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts
@@ -0,0 +1,83 @@
+import { buildDefaultScene } from "@/lib/scenes";
+import type { SerializedScene } from "@/services/storage/types";
+import type { TScene } from "@/types/timeline";
+import type { MigrationResult, ProjectRecord } from "./types";
+
+export interface TransformV0ToV1Options {
+ now?: Date;
+}
+
+export function transformProjectV0ToV1({
+ project,
+ options = {},
+}: {
+ project: ProjectRecord;
+ options?: TransformV0ToV1Options;
+}): MigrationResult {
+ const { now = new Date() } = options;
+
+ const scenesValue = project.scenes;
+ if (Array.isArray(scenesValue) && scenesValue.length > 0) {
+ return { project, skipped: true, reason: "already has scenes" };
+ }
+
+ const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" });
+ const serializedScene = serializeScene({ scene: mainScene });
+ const updatedProject: ProjectRecord = {
+ ...project,
+ scenes: [serializedScene],
+ currentSceneId: mainScene.id,
+ version: 1,
+ };
+
+ const updatedAt = now.toISOString();
+ if (isRecord(project.metadata)) {
+ updatedProject.metadata = {
+ ...project.metadata,
+ updatedAt,
+ };
+ } else {
+ updatedProject.updatedAt = updatedAt;
+ }
+
+ return { project: updatedProject, skipped: false };
+}
+
+export function getProjectId({
+ project,
+}: {
+ project: ProjectRecord;
+}): string | null {
+ const idValue = project.id;
+ if (typeof idValue === "string" && idValue.length > 0) {
+ return idValue;
+ }
+
+ const metadataValue = project.metadata;
+ if (!isRecord(metadataValue)) {
+ return null;
+ }
+
+ const metadataId = metadataValue.id;
+ if (typeof metadataId === "string" && metadataId.length > 0) {
+ return metadataId;
+ }
+
+ return null;
+}
+
+function serializeScene({ scene }: { scene: TScene }): SerializedScene {
+ return {
+ id: scene.id,
+ name: scene.name,
+ isMain: scene.isMain,
+ tracks: scene.tracks,
+ bookmarks: scene.bookmarks,
+ createdAt: scene.createdAt.toISOString(),
+ updatedAt: scene.updatedAt.toISOString(),
+ };
+}
+
+function isRecord(value: unknown): value is ProjectRecord {
+ return typeof value === "object" && value !== null;
+}
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
new file mode 100644
index 00000000..a6aaa195
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
@@ -0,0 +1,326 @@
+import {
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_CANVAS_SIZE,
+ DEFAULT_COLOR,
+ DEFAULT_FPS,
+} from "@/constants/project-constants";
+import type { MigrationResult, ProjectRecord } from "./types";
+
+export function transformProjectV1ToV2({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV2Project({ project })) {
+ return { project, skipped: true, reason: "already v2" };
+ }
+
+ const migratedProject = migrateProject({ project, projectId });
+ return { project: migratedProject, skipped: false };
+}
+
+function migrateProject({
+ project,
+ projectId,
+}: {
+ project: ProjectRecord;
+ projectId: string;
+}): ProjectRecord {
+ const createdAt = normalizeDateString({ value: project.createdAt });
+ const updatedAt = normalizeDateString({ value: project.updatedAt });
+ const metadataValue = project.metadata;
+
+ const metadata = isRecord(metadataValue)
+ ? {
+ id: getStringValue({ value: metadataValue.id, fallback: projectId }),
+ name: getStringValue({ value: metadataValue.name, fallback: "" }),
+ thumbnail: getStringValue({ value: metadataValue.thumbnail }),
+ createdAt: normalizeDateString({ value: metadataValue.createdAt }),
+ updatedAt: normalizeDateString({ value: metadataValue.updatedAt }),
+ }
+ : {
+ id: projectId,
+ name: getStringValue({ value: project.name, fallback: "" }),
+ thumbnail: getStringValue({ value: project.thumbnail }),
+ createdAt,
+ updatedAt,
+ };
+
+ const scenesValue = project.scenes;
+ const scenes = Array.isArray(scenesValue) ? scenesValue : [];
+ const legacyBookmarks = Array.isArray(project.bookmarks)
+ ? project.bookmarks
+ : null;
+ const normalizedScenes = applyLegacyBookmarks({
+ scenes,
+ legacyBookmarks,
+ });
+
+ const settingsValue = project.settings;
+ const settings = isRecord(settingsValue)
+ ? {
+ fps: getNumberValue({
+ value: settingsValue.fps,
+ fallback: DEFAULT_FPS,
+ }),
+ canvasSize: getCanvasSizeValue({
+ value: settingsValue.canvasSize,
+ fallback: DEFAULT_CANVAS_SIZE,
+ }),
+ background: getBackgroundValue({
+ value: settingsValue.background,
+ }),
+ }
+ : {
+ fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }),
+ canvasSize: getCanvasSizeValue({
+ value: project.canvasSize,
+ fallback: DEFAULT_CANVAS_SIZE,
+ }),
+ background: getBackgroundValue({
+ value: project.background,
+ backgroundType: project.backgroundType,
+ backgroundColor: project.backgroundColor,
+ blurIntensity: project.blurIntensity,
+ }),
+ };
+
+ const currentSceneId = getCurrentSceneId({
+ value: project.currentSceneId,
+ scenes: normalizedScenes,
+ });
+
+ return {
+ ...project,
+ metadata,
+ scenes: normalizedScenes,
+ currentSceneId,
+ settings,
+ version: 2,
+ };
+}
+
+export function getProjectId({
+ project,
+}: {
+ project: ProjectRecord;
+}): string | null {
+ const idValue = project.id;
+ if (typeof idValue === "string" && idValue.length > 0) {
+ return idValue;
+ }
+
+ const metadataValue = project.metadata;
+ if (!isRecord(metadataValue)) {
+ return null;
+ }
+
+ const metadataId = metadataValue.id;
+ if (typeof metadataId === "string" && metadataId.length > 0) {
+ return metadataId;
+ }
+
+ return null;
+}
+
+function getCurrentSceneId({
+ value,
+ scenes,
+}: {
+ value: unknown;
+ scenes: unknown[];
+}): string {
+ if (typeof value === "string" && value.length > 0) {
+ return value;
+ }
+
+ const mainSceneId = findMainSceneId({ scenes });
+ if (mainSceneId) {
+ return mainSceneId;
+ }
+
+ return "";
+}
+
+function findMainSceneId({ scenes }: { scenes: unknown[] }): string | null {
+ for (const scene of scenes) {
+ if (!isRecord(scene)) {
+ continue;
+ }
+
+ if (scene.isMain === true && typeof scene.id === "string") {
+ return scene.id;
+ }
+ }
+
+ for (const scene of scenes) {
+ if (!isRecord(scene)) {
+ continue;
+ }
+
+ if (typeof scene.id === "string") {
+ return scene.id;
+ }
+ }
+
+ return null;
+}
+
+function applyLegacyBookmarks({
+ scenes,
+ legacyBookmarks,
+}: {
+ scenes: unknown[];
+ legacyBookmarks: unknown[] | null;
+}): unknown[] {
+ if (!legacyBookmarks || legacyBookmarks.length === 0) {
+ return scenes;
+ }
+
+ const mainSceneId = findMainSceneId({ scenes });
+
+ return scenes.map((scene) => {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ if (mainSceneId && scene.id !== mainSceneId) {
+ return scene;
+ }
+
+ if (Array.isArray(scene.bookmarks) && scene.bookmarks.length > 0) {
+ return scene;
+ }
+
+ return {
+ ...scene,
+ bookmarks: legacyBookmarks,
+ };
+ });
+}
+
+function getBackgroundValue({
+ value,
+ backgroundType,
+ backgroundColor,
+ blurIntensity,
+}: {
+ value?: unknown;
+ backgroundType?: unknown;
+ backgroundColor?: unknown;
+ blurIntensity?: unknown;
+}): {
+ type: "color" | "blur";
+ color?: string;
+ blurIntensity?: number;
+} {
+ if (isRecord(value)) {
+ const typeValue = value.type;
+ if (typeValue === "blur") {
+ return {
+ type: "blur",
+ blurIntensity: getNumberValue({
+ value: value.blurIntensity,
+ fallback: DEFAULT_BLUR_INTENSITY,
+ }),
+ };
+ }
+
+ return {
+ type: "color",
+ color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }),
+ };
+ }
+
+ if (backgroundType === "blur") {
+ return {
+ type: "blur",
+ blurIntensity: getNumberValue({
+ value: blurIntensity,
+ fallback: DEFAULT_BLUR_INTENSITY,
+ }),
+ };
+ }
+
+ return {
+ type: "color",
+ color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }),
+ };
+}
+
+function getCanvasSizeValue({
+ value,
+ fallback,
+}: {
+ value: unknown;
+ fallback: { width: number; height: number };
+}): { width: number; height: number } {
+ if (isRecord(value)) {
+ const width = getNumberValue({
+ value: value.width,
+ fallback: fallback.width,
+ });
+ const height = getNumberValue({
+ value: value.height,
+ fallback: fallback.height,
+ });
+
+ return { width, height };
+ }
+
+ return fallback;
+}
+
+function getNumberValue({
+ value,
+ fallback,
+}: {
+ value: unknown;
+ fallback: number;
+}): number {
+ return typeof value === "number" ? value : fallback;
+}
+
+function getStringValue({
+ value,
+ fallback,
+}: {
+ value: unknown;
+ fallback?: string;
+}): string | undefined {
+ if (typeof value === "string") {
+ return value;
+ }
+
+ return fallback;
+}
+
+function normalizeDateString({ value }: { value: unknown }): string {
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+
+ if (typeof value === "string") {
+ return value;
+ }
+
+ return new Date().toISOString();
+}
+
+function isV2Project({ project }: { project: ProjectRecord }): boolean {
+ const versionValue = project.version;
+ if (typeof versionValue === "number" && versionValue >= 2) {
+ return true;
+ }
+
+ return isRecord(project.metadata) && isRecord(project.settings);
+}
+
+function isRecord(value: unknown): value is ProjectRecord {
+ return typeof value === "object" && value !== null;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts b/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts
new file mode 100644
index 00000000..33656109
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts
@@ -0,0 +1,81 @@
+import { getProjectDurationFromScenes } from "@/lib/scenes";
+import type { TScene } from "@/types/timeline";
+import type { MigrationResult, ProjectRecord } from "./types";
+
+export function transformProjectV2ToV3({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV3Project({ project })) {
+ return { project, skipped: true, reason: "already v3" };
+ }
+
+ const scenes = getScenes({ project });
+ const duration = getProjectDurationFromScenes({ scenes });
+
+ const metadataValue = project.metadata;
+ const metadata = isRecord(metadataValue)
+ ? { ...metadataValue, duration }
+ : { duration };
+
+ const migratedProject = {
+ ...project,
+ metadata,
+ version: 3,
+ };
+
+ return { project: migratedProject, skipped: false };
+}
+
+export function getProjectId({
+ project,
+}: {
+ project: ProjectRecord;
+}): string | null {
+ const idValue = project.id;
+ if (typeof idValue === "string" && idValue.length > 0) {
+ return idValue;
+ }
+
+ const metadataValue = project.metadata;
+ if (!isRecord(metadataValue)) {
+ return null;
+ }
+
+ const metadataId = metadataValue.id;
+ if (typeof metadataId === "string" && metadataId.length > 0) {
+ return metadataId;
+ }
+
+ return null;
+}
+
+function getScenes({ project }: { project: ProjectRecord }): TScene[] {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) {
+ return [];
+ }
+
+ return scenesValue.filter(isRecord) as unknown as TScene[];
+}
+
+function isV3Project({ project }: { project: ProjectRecord }): boolean {
+ const versionValue = project.version;
+ if (typeof versionValue === "number" && versionValue >= 3) {
+ return true;
+ }
+
+ return (
+ isRecord(project.metadata) && typeof project.metadata.duration === "number"
+ );
+}
+
+function isRecord(value: unknown): value is ProjectRecord {
+ return typeof value === "object" && value !== null;
+}
diff --git a/apps/web/src/services/storage/migrations/v0-to-v1.ts b/apps/web/src/services/storage/migrations/v0-to-v1.ts
index 4997f3fa..e183ddf2 100644
--- a/apps/web/src/services/storage/migrations/v0-to-v1.ts
+++ b/apps/web/src/services/storage/migrations/v0-to-v1.ts
@@ -5,12 +5,8 @@
*/
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
-import type { SerializedScene } from "@/services/storage/types";
-import { buildDefaultScene } from "@/lib/scenes";
-import type { TScene } from "@/types/timeline";
import { StorageMigration } from "./base";
-
-type ProjectRecord = Record;
+import { getProjectId, transformProjectV0ToV1 } from "./transformers/v0-to-v1";
export class V0toV1Migration extends StorageMigration {
from = 0;
@@ -25,75 +21,24 @@ export class V0toV1Migration extends StorageMigration {
const projects = await projectsAdapter.getAll();
for (const project of projects) {
- if (!isRecord(project)) {
+ if (typeof project !== "object" || project === null) {
continue;
}
- const scenesValue = project.scenes;
- if (Array.isArray(scenesValue) && scenesValue.length > 0) {
+ const result = transformProjectV0ToV1({
+ project: project as Record,
+ });
+
+ if (result.skipped) {
continue;
}
- const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" });
- const serializedScene = serializeScene({ scene: mainScene });
- const updatedProject: ProjectRecord = {
- ...project,
- scenes: [serializedScene],
- currentSceneId: mainScene.id,
- version: 1,
- };
-
- const updatedAt = new Date().toISOString();
- if (isRecord(project.metadata)) {
- updatedProject.metadata = {
- ...project.metadata,
- updatedAt,
- };
- } else {
- updatedProject.updatedAt = updatedAt;
- }
-
- const projectId = getProjectId({ project: updatedProject });
+ const projectId = getProjectId({ project: result.project });
if (!projectId) {
continue;
}
- await projectsAdapter.set(projectId, updatedProject);
+ await projectsAdapter.set(projectId, result.project);
}
}
}
-
-function serializeScene({ scene }: { scene: TScene }): SerializedScene {
- return {
- id: scene.id,
- name: scene.name,
- isMain: scene.isMain,
- tracks: scene.tracks,
- bookmarks: scene.bookmarks,
- createdAt: scene.createdAt.toISOString(),
- updatedAt: scene.updatedAt.toISOString(),
- };
-}
-
-function getProjectId({ project }: { project: ProjectRecord }): string | null {
- const idValue = project.id;
- if (typeof idValue === "string" && idValue.length > 0) {
- return idValue;
- }
-
- const metadataValue = project.metadata;
- if (!isRecord(metadataValue)) {
- return null;
- }
-
- const metadataId = metadataValue.id;
- if (typeof metadataId === "string" && metadataId.length > 0) {
- return metadataId;
- }
-
- return null;
-}
-
-function isRecord(value: unknown): value is ProjectRecord {
- return typeof value === "object" && value !== null;
-}
diff --git a/apps/web/src/services/storage/migrations/v1-to-v2.ts b/apps/web/src/services/storage/migrations/v1-to-v2.ts
index b108f507..a4e1f20b 100644
--- a/apps/web/src/services/storage/migrations/v1-to-v2.ts
+++ b/apps/web/src/services/storage/migrations/v1-to-v2.ts
@@ -2,16 +2,9 @@
* Adds default values for new project properties introduced in v2
*/
-import {
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_CANVAS_SIZE,
- DEFAULT_COLOR,
- DEFAULT_FPS,
-} from "@/constants/project-constants";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { StorageMigration } from "./base";
-
-type ProjectRecord = Record;
+import { getProjectId, transformProjectV1ToV2 } from "./transformers/v1-to-v2";
export class V1toV2Migration extends StorageMigration {
from = 1;
@@ -26,318 +19,24 @@ export class V1toV2Migration extends StorageMigration {
const projects = await projectsAdapter.getAll();
for (const project of projects) {
- if (!isRecord(project)) {
+ if (typeof project !== "object" || project === null) {
continue;
}
- const projectId = getProjectId({ project });
+ const result = transformProjectV1ToV2({
+ project: project as Record,
+ });
+
+ if (result.skipped) {
+ continue;
+ }
+
+ const projectId = getProjectId({ project: result.project });
if (!projectId) {
continue;
}
- if (isV2Project({ project })) {
- continue;
- }
-
- const migratedProject = migrateProject({ project, projectId });
- await projectsAdapter.set(projectId, migratedProject);
+ await projectsAdapter.set(projectId, result.project);
}
}
}
-
-function migrateProject({
- project,
- projectId,
-}: {
- project: ProjectRecord;
- projectId: string;
-}): ProjectRecord {
- const createdAt = normalizeDateString({ value: project.createdAt });
- const updatedAt = normalizeDateString({ value: project.updatedAt });
- const metadataValue = project.metadata;
-
- const metadata = isRecord(metadataValue)
- ? {
- id: getStringValue({ value: metadataValue.id, fallback: projectId }),
- name: getStringValue({ value: metadataValue.name, fallback: "" }),
- thumbnail: getStringValue({ value: metadataValue.thumbnail }),
- createdAt: normalizeDateString({ value: metadataValue.createdAt }),
- updatedAt: normalizeDateString({ value: metadataValue.updatedAt }),
- }
- : {
- id: projectId,
- name: getStringValue({ value: project.name, fallback: "" }),
- thumbnail: getStringValue({ value: project.thumbnail }),
- createdAt,
- updatedAt,
- };
-
- const scenesValue = project.scenes;
- const scenes = Array.isArray(scenesValue) ? scenesValue : [];
- const legacyBookmarks = Array.isArray(project.bookmarks)
- ? project.bookmarks
- : null;
- const normalizedScenes = applyLegacyBookmarks({
- scenes,
- legacyBookmarks,
- });
-
- const settingsValue = project.settings;
- const settings = isRecord(settingsValue)
- ? {
- fps: getNumberValue({
- value: settingsValue.fps,
- fallback: DEFAULT_FPS,
- }),
- canvasSize: getCanvasSizeValue({
- value: settingsValue.canvasSize,
- fallback: DEFAULT_CANVAS_SIZE,
- }),
- background: getBackgroundValue({
- value: settingsValue.background,
- }),
- }
- : {
- fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }),
- canvasSize: getCanvasSizeValue({
- value: project.canvasSize,
- fallback: DEFAULT_CANVAS_SIZE,
- }),
- background: getBackgroundValue({
- value: project.background,
- backgroundType: project.backgroundType,
- backgroundColor: project.backgroundColor,
- blurIntensity: project.blurIntensity,
- }),
- };
-
- const currentSceneId = getCurrentSceneId({
- value: project.currentSceneId,
- scenes: normalizedScenes,
- });
-
- return {
- ...project,
- metadata,
- scenes: normalizedScenes,
- currentSceneId,
- settings,
- version: 2,
- };
-}
-
-function getProjectId({ project }: { project: ProjectRecord }): string | null {
- const idValue = project.id;
- if (typeof idValue === "string" && idValue.length > 0) {
- return idValue;
- }
-
- const metadataValue = project.metadata;
- if (!isRecord(metadataValue)) {
- return null;
- }
-
- const metadataId = metadataValue.id;
- if (typeof metadataId === "string" && metadataId.length > 0) {
- return metadataId;
- }
-
- return null;
-}
-
-function getCurrentSceneId({
- value,
- scenes,
-}: {
- value: unknown;
- scenes: unknown[];
-}): string {
- if (typeof value === "string" && value.length > 0) {
- return value;
- }
-
- const mainSceneId = findMainSceneId({ scenes });
- if (mainSceneId) {
- return mainSceneId;
- }
-
- return "";
-}
-
-function findMainSceneId({ scenes }: { scenes: unknown[] }): string | null {
- for (const scene of scenes) {
- if (!isRecord(scene)) {
- continue;
- }
-
- if (scene.isMain === true && typeof scene.id === "string") {
- return scene.id;
- }
- }
-
- for (const scene of scenes) {
- if (!isRecord(scene)) {
- continue;
- }
-
- if (typeof scene.id === "string") {
- return scene.id;
- }
- }
-
- return null;
-}
-
-function applyLegacyBookmarks({
- scenes,
- legacyBookmarks,
-}: {
- scenes: unknown[];
- legacyBookmarks: unknown[] | null;
-}): unknown[] {
- if (!legacyBookmarks || legacyBookmarks.length === 0) {
- return scenes;
- }
-
- const mainSceneId = findMainSceneId({ scenes });
-
- return scenes.map((scene) => {
- if (!isRecord(scene)) {
- return scene;
- }
-
- if (mainSceneId && scene.id !== mainSceneId) {
- return scene;
- }
-
- if (Array.isArray(scene.bookmarks) && scene.bookmarks.length > 0) {
- return scene;
- }
-
- return {
- ...scene,
- bookmarks: legacyBookmarks,
- };
- });
-}
-
-function getBackgroundValue({
- value,
- backgroundType,
- backgroundColor,
- blurIntensity,
-}: {
- value: unknown;
- backgroundType?: unknown;
- backgroundColor?: unknown;
- blurIntensity?: unknown;
-}): {
- type: "color" | "blur";
- color?: string;
- blurIntensity?: number;
-} {
- if (isRecord(value)) {
- const typeValue = value.type;
- if (typeValue === "blur") {
- return {
- type: "blur",
- blurIntensity: getNumberValue({
- value: value.blurIntensity,
- fallback: DEFAULT_BLUR_INTENSITY,
- }),
- };
- }
-
- return {
- type: "color",
- color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }),
- };
- }
-
- if (backgroundType === "blur") {
- return {
- type: "blur",
- blurIntensity: getNumberValue({
- value: blurIntensity,
- fallback: DEFAULT_BLUR_INTENSITY,
- }),
- };
- }
-
- return {
- type: "color",
- color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }),
- };
-}
-
-function getCanvasSizeValue({
- value,
- fallback,
-}: {
- value: unknown;
- fallback: { width: number; height: number };
-}): { width: number; height: number } {
- if (isRecord(value)) {
- const width = getNumberValue({
- value: value.width,
- fallback: fallback.width,
- });
- const height = getNumberValue({
- value: value.height,
- fallback: fallback.height,
- });
-
- return { width, height };
- }
-
- return fallback;
-}
-
-function getNumberValue({
- value,
- fallback,
-}: {
- value: unknown;
- fallback: number;
-}): number {
- return typeof value === "number" ? value : fallback;
-}
-
-function getStringValue({
- value,
- fallback,
-}: {
- value: unknown;
- fallback?: string;
-}): string | undefined {
- if (typeof value === "string") {
- return value;
- }
-
- return fallback;
-}
-
-function normalizeDateString({ value }: { value: unknown }): string {
- if (value instanceof Date) {
- return value.toISOString();
- }
-
- if (typeof value === "string") {
- return value;
- }
-
- return new Date().toISOString();
-}
-
-function isV2Project({ project }: { project: ProjectRecord }): boolean {
- const versionValue = project.version;
- if (typeof versionValue === "number" && versionValue >= 2) {
- return true;
- }
-
- return isRecord(project.metadata) && isRecord(project.settings);
-}
-
-function isRecord(value: unknown): value is ProjectRecord {
- return typeof value === "object" && value !== null;
-}
diff --git a/apps/web/src/services/storage/migrations/v2-to-v3.ts b/apps/web/src/services/storage/migrations/v2-to-v3.ts
index 7869812c..7c52f79d 100644
--- a/apps/web/src/services/storage/migrations/v2-to-v3.ts
+++ b/apps/web/src/services/storage/migrations/v2-to-v3.ts
@@ -1,12 +1,10 @@
/*
- * Adds a "duration" field to each project's metadata,
+ * Adds a "duration" field to each project's metadata
*/
-import { getProjectDurationFromScenes } from "@/lib/scenes";
-import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
-import type { TScene } from "@/types/timeline";
-import { StorageMigration } from "./base";
-type ProjectRecord = Record;
+import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
+import { StorageMigration } from "./base";
+import { getProjectId, transformProjectV2ToV3 } from "./transformers/v2-to-v3";
export class V2toV3Migration extends StorageMigration {
from = 2;
@@ -21,77 +19,24 @@ export class V2toV3Migration extends StorageMigration {
const projects = await projectsAdapter.getAll();
for (const project of projects) {
- if (!isRecord(project)) {
+ if (typeof project !== "object" || project === null) {
continue;
}
- const projectId = getProjectId({ project });
+ const result = transformProjectV2ToV3({
+ project: project as Record,
+ });
+
+ if (result.skipped) {
+ continue;
+ }
+
+ const projectId = getProjectId({ project: result.project });
if (!projectId) {
continue;
}
- if (isV3Project({ project })) {
- continue;
- }
-
- const scenes = getScenes({ project });
- const duration = getProjectDurationFromScenes({ scenes });
-
- const metadataValue = project.metadata;
- const metadata = isRecord(metadataValue)
- ? { ...metadataValue, duration }
- : { duration };
-
- const migratedProject = {
- ...project,
- metadata,
- version: 3,
- };
-
- await projectsAdapter.set(projectId, migratedProject);
+ await projectsAdapter.set(projectId, result.project);
}
}
}
-
-function getProjectId({ project }: { project: ProjectRecord }): string | null {
- const idValue = project.id;
- if (typeof idValue === "string" && idValue.length > 0) {
- return idValue;
- }
-
- const metadataValue = project.metadata;
- if (!isRecord(metadataValue)) {
- return null;
- }
-
- const metadataId = metadataValue.id;
- if (typeof metadataId === "string" && metadataId.length > 0) {
- return metadataId;
- }
-
- return null;
-}
-
-function getScenes({ project }: { project: ProjectRecord }): TScene[] {
- const scenesValue = project.scenes;
- if (!Array.isArray(scenesValue)) {
- return [];
- }
-
- return scenesValue.filter(isRecord) as unknown as TScene[];
-}
-
-function isV3Project({ project }: { project: ProjectRecord }): boolean {
- const versionValue = project.version;
- if (typeof versionValue === "number" && versionValue >= 3) {
- return true;
- }
-
- return (
- isRecord(project.metadata) && typeof project.metadata.duration === "number"
- );
-}
-
-function isRecord(value: unknown): value is ProjectRecord {
- return typeof value === "object" && value !== null;
-}
diff --git a/package.json b/package.json
index 9e4a07dc..be5d523e 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
"start:tools": "turbo run start --filter=@opencut/tools",
"lint:web": "biome lint apps/web/src --max-diagnostics=1000",
"lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000",
- "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000"
+ "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
+ "test": "bun test"
},
"dependencies": {
"@types/react": "^19.2.10",