diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-07 01:09:13 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-07 01:09:13 +0200 |
| commit | e4b67094e757917588fb624a30aa046667403add (patch) | |
| tree | 55452692eac69a5a63b58f2a5cdb701dd7023d68 /apps/web/src/services/storage/migrations | |
| parent | 79df73643185c02493364a077402726d1fa7b9ec (diff) | |
| download | opencut-e4b67094e757917588fb624a30aa046667403add.tar.gz | |
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'apps/web/src/services/storage/migrations')
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 }); + } +} |
