summaryrefslogtreecommitdiff
path: root/apps/web/src/services
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
commite4b67094e757917588fb624a30aa046667403add (patch)
tree55452692eac69a5a63b58f2a5cdb701dd7023d68 /apps/web/src/services
parent79df73643185c02493364a077402726d1fa7b9ec (diff)
downloadopencut-e4b67094e757917588fb624a30aa046667403add.tar.gz
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'apps/web/src/services')
-rw-r--r--apps/web/src/services/renderer/canvas-renderer.ts6
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts9
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts76
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts3
-rw-r--r--apps/web/src/services/renderer/scene-exporter.ts333
-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
11 files changed, 766 insertions, 212 deletions
diff --git a/apps/web/src/services/renderer/canvas-renderer.ts b/apps/web/src/services/renderer/canvas-renderer.ts
index 840d8b52..b142d97a 100644
--- a/apps/web/src/services/renderer/canvas-renderer.ts
+++ b/apps/web/src/services/renderer/canvas-renderer.ts
@@ -1,9 +1,11 @@
+import type { FrameRate } from "opencut-wasm";
+import { frameRateToFloat } from "@/lib/fps/utils";
import type { BaseNode } from "./nodes/base-node";
export type CanvasRendererParams = {
width: number;
height: number;
- fps: number;
+ fps: FrameRate;
};
export class CanvasRenderer {
@@ -11,7 +13,7 @@ export class CanvasRenderer {
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
width: number;
height: number;
- fps: number;
+ fps: FrameRate;
constructor({ width, height, fps }: CanvasRendererParams) {
this.width = width;
diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts
index c67a5113..4cb45b33 100644
--- a/apps/web/src/services/renderer/nodes/blur-background-node.ts
+++ b/apps/web/src/services/renderer/nodes/blur-background-node.ts
@@ -1,5 +1,5 @@
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { buildGaussianBlurPasses, intensityToSigma } from "@/lib/effects/definitions/blur";
+import { mediaTimeToSeconds } from "opencut-wasm";
import { getSourceTimeAtClipTime } from "@/lib/retime";
import { videoCache } from "@/services/video-cache/service";
import type { RetimeConfig } from "@/lib/timeline";
@@ -39,9 +39,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
private isInRange({ time }: { time: number }): boolean {
const localTime = time - this.params.timeOffset;
- return (
- localTime >= -TIME_EPSILON_SECONDS && localTime < this.params.duration
- );
+ return localTime >= 0 && localTime < this.params.duration;
}
private getSourceLocalTime({ time }: { time: number }): number {
@@ -61,10 +59,11 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
time: number;
}): Promise<BackdropSource | null> {
if (this.params.mediaType === "video") {
+ const sourceTimeTicks = this.getSourceLocalTime({ time });
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
- time: this.getSourceLocalTime({ time }),
+ time: mediaTimeToSeconds({ time: sourceTimeTicks }),
});
if (!frame) {
diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts
index 3bb836ec..4f225567 100644
--- a/apps/web/src/services/renderer/nodes/video-node.ts
+++ b/apps/web/src/services/renderer/nodes/video-node.ts
@@ -1,37 +1,39 @@
-import type { CanvasRenderer } from "../canvas-renderer";
-import { VisualNode, type VisualNodeParams } from "./visual-node";
-import { videoCache } from "@/services/video-cache/service";
-
-export interface VideoNodeParams extends VisualNodeParams {
- url: string;
- file: File;
- mediaId: string;
-}
-
-export class VideoNode extends VisualNode<VideoNodeParams> {
- async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
- await super.render({ renderer, time });
-
- if (!this.isInRange({ time })) {
- return;
- }
-
- const videoTime = this.getSourceLocalTime({ time });
-
- const frame = await videoCache.getFrameAt({
- mediaId: this.params.mediaId,
- file: this.params.file,
- time: videoTime,
- });
-
- if (frame) {
- this.renderVisual({
- renderer,
- source: frame.canvas,
- sourceWidth: frame.canvas.width,
- sourceHeight: frame.canvas.height,
- timelineTime: time,
- });
- }
- }
-}
+import type { CanvasRenderer } from "../canvas-renderer";
+import { mediaTimeToSeconds } from "opencut-wasm";
+import { VisualNode, type VisualNodeParams } from "./visual-node";
+import { videoCache } from "@/services/video-cache/service";
+
+export interface VideoNodeParams extends VisualNodeParams {
+ url: string;
+ file: File;
+ mediaId: string;
+}
+
+export class VideoNode extends VisualNode<VideoNodeParams> {
+ async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
+ await super.render({ renderer, time });
+
+ if (!this.isInRange({ time })) {
+ return;
+ }
+
+ const videoTimeTicks = this.getSourceLocalTime({ time });
+ const videoTimeSeconds = mediaTimeToSeconds({ time: videoTimeTicks });
+
+ const frame = await videoCache.getFrameAt({
+ mediaId: this.params.mediaId,
+ file: this.params.file,
+ time: videoTimeSeconds,
+ });
+
+ if (frame) {
+ this.renderVisual({
+ renderer,
+ source: frame.canvas,
+ sourceWidth: frame.canvas.width,
+ sourceHeight: frame.canvas.height,
+ timelineTime: time,
+ });
+ }
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 82093da4..4a5fcdc6 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -12,7 +12,6 @@ import {
resolveTransformAtTime,
} from "@/lib/animation";
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import { masksRegistry } from "@/lib/masks";
import { getSourceTimeAtClipTime } from "@/lib/retime";
@@ -58,7 +57,7 @@ export abstract class VisualNode<
protected isInRange({ time }: { time: number }): boolean {
const localTime = time - this.params.timeOffset;
return (
- localTime >= -TIME_EPSILON_SECONDS &&
+ localTime >= 0 &&
localTime < this.params.duration
);
}
diff --git a/apps/web/src/services/renderer/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts
index 2dbb7cdd..0c2ee17f 100644
--- a/apps/web/src/services/renderer/scene-exporter.ts
+++ b/apps/web/src/services/renderer/scene-exporter.ts
@@ -1,163 +1,170 @@
-import EventEmitter from "eventemitter3";
-
-import {
- Output,
- Mp4OutputFormat,
- WebMOutputFormat,
- BufferTarget,
- CanvasSource,
- AudioBufferSource,
- QUALITY_LOW,
- QUALITY_MEDIUM,
- QUALITY_HIGH,
- QUALITY_VERY_HIGH,
-} from "mediabunny";
-import type { RootNode } from "./nodes/root-node";
-import type { ExportFormat, ExportQuality } from "@/lib/export";
-import { CanvasRenderer } from "./canvas-renderer";
-
-type ExportParams = {
- width: number;
- height: number;
- fps: number;
- format: ExportFormat;
- quality: ExportQuality;
- shouldIncludeAudio?: boolean;
- audioBuffer?: AudioBuffer;
-};
-
-const qualityMap = {
- low: QUALITY_LOW,
- medium: QUALITY_MEDIUM,
- high: QUALITY_HIGH,
- very_high: QUALITY_VERY_HIGH,
-};
-
-export type SceneExporterEvents = {
- progress: [progress: number];
- complete: [buffer: ArrayBuffer];
- error: [error: Error];
- cancelled: [];
-};
-
-export class SceneExporter extends EventEmitter<SceneExporterEvents> {
- private renderer: CanvasRenderer;
- private format: ExportFormat;
- private quality: ExportQuality;
- private shouldIncludeAudio: boolean;
- private audioBuffer?: AudioBuffer;
-
- private isCancelled = false;
-
- constructor({
- width,
- height,
- fps,
- format,
- quality,
- shouldIncludeAudio,
- audioBuffer,
- }: ExportParams) {
- super();
- this.renderer = new CanvasRenderer({
- width,
- height,
- fps,
- });
-
- this.format = format;
- this.quality = quality;
- this.shouldIncludeAudio = shouldIncludeAudio ?? false;
- this.audioBuffer = audioBuffer;
- }
-
- cancel(): void {
- this.isCancelled = true;
- }
-
- async export({
- rootNode,
- }: {
- rootNode: RootNode;
- }): Promise<ArrayBuffer | null> {
- const { fps } = this.renderer;
- const frameCount = Math.ceil(rootNode.duration * fps);
-
- const outputFormat =
- this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
-
- const output = new Output({
- format: outputFormat,
- target: new BufferTarget(),
- });
-
- const videoSource = new CanvasSource(this.renderer.canvas, {
- codec: this.format === "webm" ? "vp9" : "avc",
- bitrate: qualityMap[this.quality],
- });
-
- output.addVideoTrack(videoSource, { frameRate: fps });
-
- let audioSource: AudioBufferSource | null = null;
- if (this.shouldIncludeAudio && this.audioBuffer) {
- let audioCodec: "aac" | "opus" =
- this.format === "webm" ? "opus" : "aac";
-
- if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") {
- const { supported } = await AudioEncoder.isConfigSupported({
- codec: "mp4a.40.2",
- sampleRate: this.audioBuffer.sampleRate,
- numberOfChannels: this.audioBuffer.numberOfChannels,
- bitrate: 192000,
- });
- if (!supported) audioCodec = "opus";
- }
-
- audioSource = new AudioBufferSource({
- codec: audioCodec,
- bitrate: qualityMap[this.quality],
- });
- output.addAudioTrack(audioSource);
- }
-
- await output.start();
-
- if (audioSource && this.audioBuffer) {
- await audioSource.add(this.audioBuffer);
- audioSource.close();
- }
-
- for (let i = 0; i < frameCount; i++) {
- if (this.isCancelled) {
- await output.cancel();
- this.emit("cancelled");
- return null;
- }
-
- const time = i / fps;
- await this.renderer.render({ node: rootNode, time });
- await videoSource.add(time, 1 / fps);
-
- this.emit("progress", i / frameCount);
- }
-
- if (this.isCancelled) {
- await output.cancel();
- this.emit("cancelled");
- return null;
- }
-
- videoSource.close();
- await output.finalize();
- this.emit("progress", 1);
-
- const buffer = output.target.buffer;
- if (!buffer) {
- this.emit("error", new Error("Failed to export video"));
- return null;
- }
-
- this.emit("complete", buffer);
- return buffer;
- }
-}
+import EventEmitter from "eventemitter3";
+
+import {
+ Output,
+ Mp4OutputFormat,
+ WebMOutputFormat,
+ BufferTarget,
+ CanvasSource,
+ AudioBufferSource,
+ QUALITY_LOW,
+ QUALITY_MEDIUM,
+ QUALITY_HIGH,
+ QUALITY_VERY_HIGH,
+} from "mediabunny";
+import type { FrameRate } from "opencut-wasm";
+import { mediaTimeToSeconds } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+import { frameRateToFloat } from "@/lib/fps/utils";
+import type { RootNode } from "./nodes/root-node";
+import type { ExportFormat, ExportQuality } from "@/lib/export";
+import { CanvasRenderer } from "./canvas-renderer";
+
+type ExportParams = {
+ width: number;
+ height: number;
+ fps: FrameRate;
+ format: ExportFormat;
+ quality: ExportQuality;
+ shouldIncludeAudio?: boolean;
+ audioBuffer?: AudioBuffer;
+};
+
+const qualityMap = {
+ low: QUALITY_LOW,
+ medium: QUALITY_MEDIUM,
+ high: QUALITY_HIGH,
+ very_high: QUALITY_VERY_HIGH,
+};
+
+export type SceneExporterEvents = {
+ progress: [progress: number];
+ complete: [buffer: ArrayBuffer];
+ error: [error: Error];
+ cancelled: [];
+};
+
+export class SceneExporter extends EventEmitter<SceneExporterEvents> {
+ private renderer: CanvasRenderer;
+ private format: ExportFormat;
+ private quality: ExportQuality;
+ private shouldIncludeAudio: boolean;
+ private audioBuffer?: AudioBuffer;
+
+ private isCancelled = false;
+
+ constructor({
+ width,
+ height,
+ fps,
+ format,
+ quality,
+ shouldIncludeAudio,
+ audioBuffer,
+ }: ExportParams) {
+ super();
+ this.renderer = new CanvasRenderer({
+ width,
+ height,
+ fps,
+ });
+
+ this.format = format;
+ this.quality = quality;
+ this.shouldIncludeAudio = shouldIncludeAudio ?? false;
+ this.audioBuffer = audioBuffer;
+ }
+
+ cancel(): void {
+ this.isCancelled = true;
+ }
+
+ async export({
+ rootNode,
+ }: {
+ rootNode: RootNode;
+ }): Promise<ArrayBuffer | null> {
+ const fps = this.renderer.fps;
+ const fpsFloat = frameRateToFloat(fps);
+ const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
+ const frameCount = Math.floor(rootNode.duration / ticksPerFrame);
+
+ const outputFormat =
+ this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
+
+ const output = new Output({
+ format: outputFormat,
+ target: new BufferTarget(),
+ });
+
+ const videoSource = new CanvasSource(this.renderer.canvas, {
+ codec: this.format === "webm" ? "vp9" : "avc",
+ bitrate: qualityMap[this.quality],
+ });
+
+ output.addVideoTrack(videoSource, { frameRate: fpsFloat });
+
+ let audioSource: AudioBufferSource | null = null;
+ if (this.shouldIncludeAudio && this.audioBuffer) {
+ let audioCodec: "aac" | "opus" =
+ this.format === "webm" ? "opus" : "aac";
+
+ if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") {
+ const { supported } = await AudioEncoder.isConfigSupported({
+ codec: "mp4a.40.2",
+ sampleRate: this.audioBuffer.sampleRate,
+ numberOfChannels: this.audioBuffer.numberOfChannels,
+ bitrate: 192000,
+ });
+ if (!supported) audioCodec = "opus";
+ }
+
+ audioSource = new AudioBufferSource({
+ codec: audioCodec,
+ bitrate: qualityMap[this.quality],
+ });
+ output.addAudioTrack(audioSource);
+ }
+
+ await output.start();
+
+ if (audioSource && this.audioBuffer) {
+ await audioSource.add(this.audioBuffer);
+ audioSource.close();
+ }
+
+ for (let i = 0; i < frameCount; i++) {
+ if (this.isCancelled) {
+ await output.cancel();
+ this.emit("cancelled");
+ return null;
+ }
+
+ const timeTicks = i * ticksPerFrame;
+ const timeSeconds = mediaTimeToSeconds({ time: timeTicks });
+ await this.renderer.render({ node: rootNode, time: timeTicks });
+ await videoSource.add(timeSeconds, 1 / fpsFloat);
+
+ this.emit("progress", i / frameCount);
+ }
+
+ if (this.isCancelled) {
+ await output.cancel();
+ this.emit("cancelled");
+ return null;
+ }
+
+ videoSource.close();
+ await output.finalize();
+ this.emit("progress", 1);
+
+ const buffer = output.target.buffer;
+ if (!buffer) {
+ this.emit("error", new Error("Failed to export video"));
+ return null;
+ }
+
+ this.emit("complete", buffer);
+ return buffer;
+ }
+}
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 });
+ }
+}