refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate

This commit is contained in:
Maze Winther
2026-04-07 01:09:13 +02:00
parent 79df736431
commit e4b67094e7
102 changed files with 4977 additions and 3707 deletions
@@ -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 {
@@ -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");
});
});
@@ -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(),
];
@@ -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";
@@ -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;
}
@@ -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 });
}
}