summaryrefslogtreecommitdiff
path: root/apps/web/src/services/storage
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/storage')
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts2
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts192
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts2
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts335
-rw-r--r--apps/web/src/services/storage/migrations/v22-to-v23.ts16
6 files changed, 548 insertions, 3 deletions
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
index 7a4dd641..7a0617a7 100644
--- 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
@@ -4,7 +4,7 @@ import {
DEFAULT_BACKGROUND_COLOR,
} from "@/lib/background/constants";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
-import { DEFAULT_FPS } from "@/lib/fps/constants";
+const DEFAULT_FPS = 30;
import type { MediaAssetData } from "@/services/storage/types";
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
import {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts
new file mode 100644
index 00000000..5b764b48
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV22ToV23 } from "../transformers/v22-to-v23";
+
+describe("V22 to V23 Migration", () => {
+ test("converts project time values from seconds to ticks and fps to a frame-rate object", () => {
+ const result = transformProjectV22ToV23({
+ project: {
+ id: "project-v22-time",
+ version: 22,
+ metadata: {
+ id: "project-v22-time",
+ name: "Project",
+ duration: 15.5,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ settings: {
+ fps: 29.97,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ timelineViewState: {
+ zoomLevel: 1,
+ scrollLeft: 120,
+ playheadTime: 1.25,
+ },
+ scenes: [
+ {
+ id: "scene-1",
+ bookmarks: [
+ { time: 2.5, duration: 0.75, note: "Marker", color: "#ff0000" },
+ { time: 4.5 },
+ ],
+ tracks: [
+ {
+ id: "track-1",
+ type: "video",
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ startTime: 1.25,
+ duration: 5.5,
+ trimStart: 0.25,
+ trimEnd: 0.5,
+ sourceDuration: 6.25,
+ animations: {
+ bindings: {
+ opacity: {
+ path: "opacity",
+ kind: "number",
+ components: [
+ {
+ key: "value",
+ channelId: "opacity:value",
+ },
+ ],
+ },
+ },
+ channels: {
+ "opacity:value": {
+ kind: "scalar",
+ keys: [
+ {
+ id: "key-1",
+ time: 0.5,
+ value: 1,
+ segmentToNext: "bezier",
+ tangentMode: "flat",
+ rightHandle: {
+ dt: 0.25,
+ dv: 0.2,
+ },
+ },
+ {
+ id: "key-2",
+ time: 1.0,
+ value: 0.4,
+ segmentToNext: "linear",
+ tangentMode: "flat",
+ leftHandle: {
+ dt: -0.125,
+ dv: -0.1,
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ ],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(23);
+
+ const metadata = result.project.metadata as Record<string, unknown>;
+ expect(metadata.duration).toBe(1_860_000);
+
+ const settings = result.project.settings as Record<string, unknown>;
+ expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 });
+
+ const timelineViewState = result.project.timelineViewState as Record<
+ string,
+ unknown
+ >;
+ expect(timelineViewState.playheadTime).toBe(150_000);
+ expect(timelineViewState.scrollLeft).toBe(120);
+
+ const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scene = scenes[0];
+ expect(scene.bookmarks).toEqual([
+ {
+ time: 300_000,
+ duration: 90_000,
+ note: "Marker",
+ color: "#ff0000",
+ },
+ { time: 540_000 },
+ ]);
+
+ const tracks = scene.tracks as Array<Record<string, unknown>>;
+ const elements = tracks[0].elements as Array<Record<string, unknown>>;
+ const element = elements[0];
+ expect(element.startTime).toBe(150_000);
+ expect(element.duration).toBe(660_000);
+ expect(element.trimStart).toBe(30_000);
+ expect(element.trimEnd).toBe(60_000);
+ expect(element.sourceDuration).toBe(750_000);
+
+ const animations = element.animations as Record<string, unknown>;
+ const channels = animations.channels as Record<string, Record<string, unknown>>;
+ expect(channels["opacity:value"]).toEqual({
+ kind: "scalar",
+ keys: [
+ {
+ id: "key-1",
+ time: 60_000,
+ value: 1,
+ segmentToNext: "bezier",
+ tangentMode: "flat",
+ rightHandle: {
+ dt: 30_000,
+ dv: 0.2,
+ },
+ },
+ {
+ id: "key-2",
+ time: 120_000,
+ value: 0.4,
+ segmentToNext: "linear",
+ tangentMode: "flat",
+ leftHandle: {
+ dt: -15_000,
+ dv: -0.1,
+ },
+ },
+ ],
+ });
+ });
+
+ test("skips projects already on v23", () => {
+ const result = transformProjectV22ToV23({
+ project: {
+ id: "project-v23",
+ version: 23,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v23");
+ });
+
+ test("skips projects not on v22", () => {
+ const result = transformProjectV22ToV23({
+ project: {
+ id: "project-v21",
+ version: 21,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("not v22");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index ef41673e..9f0f3e0d 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -21,10 +21,11 @@ import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21";
import { V21toV22Migration } from "./v21-to-v22";
+import { V22toV23Migration } from "./v22-to-v23";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 22;
+export const CURRENT_PROJECT_VERSION = 23;
export const migrations = [
new V0toV1Migration(),
@@ -49,4 +50,5 @@ export const migrations = [
new V19toV20Migration(),
new V20toV21Migration(),
new V21toV22Migration(),
+ new V22toV23Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
index a2c72478..6313c311 100644
--- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
@@ -3,7 +3,7 @@ import {
DEFAULT_BACKGROUND_COLOR,
} from "@/lib/background/constants";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
-import { DEFAULT_FPS } from "@/lib/fps/constants";
+const DEFAULT_FPS = 30;
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { MediaAssetData } from "@/services/storage/types";
import type { MigrationResult, ProjectRecord } from "./types";
diff --git a/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts b/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts
new file mode 100644
index 00000000..cde1294e
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts
@@ -0,0 +1,335 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+const ARBITRARY_FPS_DENOMINATOR = 1_000_000;
+const STANDARD_FRAME_RATES = [
+ { value: 24_000 / 1_001, numerator: 24_000, denominator: 1_001 },
+ { value: 24, numerator: 24, denominator: 1 },
+ { value: 25, numerator: 25, denominator: 1 },
+ { value: 30_000 / 1_001, numerator: 30_000, denominator: 1_001 },
+ { value: 30, numerator: 30, denominator: 1 },
+ { value: 48, numerator: 48, denominator: 1 },
+ { value: 50, numerator: 50, denominator: 1 },
+ { value: 60_000 / 1_001, numerator: 60_000, denominator: 1_001 },
+ { value: 60, numerator: 60, denominator: 1 },
+ { value: 120, numerator: 120, denominator: 1 },
+] as const;
+const STANDARD_FRAME_RATE_TOLERANCE = 0.01;
+
+export function transformProjectV22ToV23({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 23) {
+ return { project, skipped: true, reason: "already v23" };
+ }
+ if (version !== 22) {
+ return { project, skipped: true, reason: "not v22" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 23,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const nextProject = { ...project };
+
+ if (isRecord(project.metadata)) {
+ nextProject.metadata = migrateMetadata({ metadata: project.metadata });
+ }
+
+ if (isRecord(project.settings)) {
+ nextProject.settings = migrateSettings({ settings: project.settings });
+ }
+
+ if (isRecord(project.timelineViewState)) {
+ nextProject.timelineViewState = migrateTimelineViewState({
+ timelineViewState: project.timelineViewState,
+ });
+ }
+
+ if (Array.isArray(project.scenes)) {
+ nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
+ }
+
+ return nextProject;
+}
+
+function migrateMetadata({
+ metadata,
+}: {
+ metadata: ProjectRecord;
+}): ProjectRecord {
+ return migrateTimeFields({
+ record: metadata,
+ keys: ["duration"],
+ });
+}
+
+function migrateSettings({
+ settings,
+}: {
+ settings: ProjectRecord;
+}): ProjectRecord {
+ const nextSettings = { ...settings };
+ if ("fps" in settings) {
+ nextSettings.fps = migrateFrameRate({ fps: settings.fps });
+ }
+ return nextSettings;
+}
+
+function migrateTimelineViewState({
+ timelineViewState,
+}: {
+ timelineViewState: ProjectRecord;
+}): ProjectRecord {
+ return migrateTimeFields({
+ record: timelineViewState,
+ keys: ["playheadTime"],
+ });
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const nextScene = { ...scene };
+
+ if (Array.isArray(scene.bookmarks)) {
+ nextScene.bookmarks = scene.bookmarks.map((bookmark) =>
+ migrateBookmark({ bookmark }),
+ );
+ }
+
+ if (Array.isArray(scene.tracks)) {
+ nextScene.tracks = scene.tracks.map((track) => migrateTrack({ track }));
+ }
+
+ return nextScene;
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) {
+ return track;
+ }
+
+ if (!Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) {
+ return element;
+ }
+
+ const nextElement = migrateTimeFields({
+ record: element,
+ keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"],
+ });
+
+ if (isRecord(element.animations)) {
+ nextElement.animations = migrateAnimations({
+ animations: element.animations,
+ });
+ }
+
+ return nextElement;
+}
+
+function migrateAnimations({
+ animations,
+}: {
+ animations: ProjectRecord;
+}): ProjectRecord {
+ if (!isRecord(animations.channels)) {
+ return animations;
+ }
+
+ return {
+ ...animations,
+ channels: Object.fromEntries(
+ Object.entries(animations.channels).map(([channelId, channel]) => [
+ channelId,
+ migrateAnimationChannel({ channel }),
+ ]),
+ ),
+ };
+}
+
+function migrateAnimationChannel({ channel }: { channel: unknown }): unknown {
+ if (!isRecord(channel)) {
+ return channel;
+ }
+
+ if (!Array.isArray(channel.keys)) {
+ return channel;
+ }
+
+ return {
+ ...channel,
+ keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })),
+ };
+}
+
+function migrateAnimationKeyframe({
+ keyframe,
+}: {
+ keyframe: unknown;
+}): unknown {
+ if (!isRecord(keyframe)) {
+ return keyframe;
+ }
+
+ const nextKeyframe = migrateTimeFields({
+ record: keyframe,
+ keys: ["time"],
+ });
+
+ if (isRecord(keyframe.leftHandle)) {
+ nextKeyframe.leftHandle = migrateCurveHandle({
+ handle: keyframe.leftHandle,
+ });
+ }
+
+ if (isRecord(keyframe.rightHandle)) {
+ nextKeyframe.rightHandle = migrateCurveHandle({
+ handle: keyframe.rightHandle,
+ });
+ }
+
+ return nextKeyframe;
+}
+
+function migrateCurveHandle({
+ handle,
+}: {
+ handle: ProjectRecord;
+}): ProjectRecord {
+ return migrateTimeFields({
+ record: handle,
+ keys: ["dt"],
+ });
+}
+
+function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown {
+ if (!isRecord(bookmark)) {
+ return bookmark;
+ }
+
+ return migrateTimeFields({
+ record: bookmark,
+ keys: ["time", "duration"],
+ });
+}
+
+function migrateTimeFields({
+ record,
+ keys,
+}: {
+ record: ProjectRecord;
+ keys: string[];
+}): ProjectRecord {
+ const nextRecord = { ...record };
+
+ for (const key of keys) {
+ if (!(key in record)) {
+ continue;
+ }
+
+ nextRecord[key] = migrateTimeValue({ value: record[key] });
+ }
+
+ return nextRecord;
+}
+
+function migrateTimeValue({ value }: { value: unknown }): unknown {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return value;
+ }
+
+ return secondsToTicks({ value });
+}
+
+function secondsToTicks({ value }: { value: number }): number {
+ return Math.round(value * TICKS_PER_SECOND);
+}
+
+function migrateFrameRate({ fps }: { fps: unknown }): unknown {
+ if (isRecord(fps)) {
+ return fps;
+ }
+
+ if (typeof fps !== "number" || !Number.isFinite(fps) || fps <= 0) {
+ return fps;
+ }
+
+ const standardFrameRate = STANDARD_FRAME_RATES.find(
+ (candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE,
+ );
+ if (standardFrameRate) {
+ return {
+ numerator: standardFrameRate.numerator,
+ denominator: standardFrameRate.denominator,
+ };
+ }
+
+ if (Number.isInteger(fps)) {
+ return { numerator: fps, denominator: 1 };
+ }
+
+ const scaledNumerator = Math.round(fps * ARBITRARY_FPS_DENOMINATOR);
+ const divisor = greatestCommonDivisor({
+ left: scaledNumerator,
+ right: ARBITRARY_FPS_DENOMINATOR,
+ });
+
+ return {
+ numerator: scaledNumerator / divisor,
+ denominator: ARBITRARY_FPS_DENOMINATOR / divisor,
+ };
+}
+
+function greatestCommonDivisor({
+ left,
+ right,
+}: {
+ left: number;
+ right: number;
+}): number {
+ let nextLeft = Math.abs(left);
+ let nextRight = Math.abs(right);
+
+ while (nextRight !== 0) {
+ const remainder = nextLeft % nextRight;
+ nextLeft = nextRight;
+ nextRight = remainder;
+ }
+
+ return nextLeft || 1;
+}
diff --git a/apps/web/src/services/storage/migrations/v22-to-v23.ts b/apps/web/src/services/storage/migrations/v22-to-v23.ts
new file mode 100644
index 00000000..cd352e45
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v22-to-v23.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV22ToV23 } from "./transformers/v22-to-v23";
+
+export class V22toV23Migration extends StorageMigration {
+ from = 22;
+ to = 23;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV22ToV23({ project });
+ }
+}