add tests for storage migrations
This commit is contained in:
parent
03c2b16064
commit
2759d7ed31
|
|
@ -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";
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
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: [],
|
||||
};
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const background = settings.background as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const background = settings.background as Record<string, unknown>;
|
||||
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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
expect(settings.fps).toBe(DEFAULT_FPS);
|
||||
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
|
||||
|
||||
const background = settings.background as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const background = settings.background as Record<string, unknown>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { getProjectId, transformProjectV2ToV3 } from "../transformers/v2-to-v3";
|
||||
import {
|
||||
projectWithNoId,
|
||||
v2Project,
|
||||
v2ProjectEmptyScenes,
|
||||
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<string, unknown>;
|
||||
expect(typeof metadata.duration).toBe("number");
|
||||
});
|
||||
|
||||
test("calculates duration from scene tracks", () => {
|
||||
const result = transformProjectV2ToV3({ project: v2Project });
|
||||
|
||||
const metadata = result.project.metadata as Record<string, unknown>;
|
||||
// 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<string, unknown>;
|
||||
// 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -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<string, unknown>;
|
||||
|
||||
export interface MigrationResult<T> {
|
||||
project: T;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
|
@ -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<ProjectRecord> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<ProjectRecord> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<ProjectRecord> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,10 +1,6 @@
|
|||
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<string, unknown>;
|
||||
import { getProjectId, transformProjectV0ToV1 } from "./transformers/v0-to-v1";
|
||||
|
||||
export class V0toV1Migration extends StorageMigration {
|
||||
from = 0;
|
||||
|
|
@ -19,75 +15,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<string, unknown>,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
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<string, unknown>;
|
||||
import { getProjectId, transformProjectV1ToV2 } from "./transformers/v1-to-v2";
|
||||
|
||||
export class V1toV2Migration extends StorageMigration {
|
||||
from = 1;
|
||||
|
|
@ -22,318 +15,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<string, unknown>,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
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<string, unknown>;
|
||||
import { getProjectId, transformProjectV2ToV3 } from "./transformers/v2-to-v3";
|
||||
|
||||
export class V2toV3Migration extends StorageMigration {
|
||||
from = 2;
|
||||
|
|
@ -18,75 +15,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<string, unknown>,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue