mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
fix: model editor time as wasm MediaTime ticks end-to-end
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveColorAtTime,
|
||||
@@ -204,7 +204,7 @@ async function resolveVideoNode({
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: node.params.mediaId,
|
||||
file: node.params.file,
|
||||
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
|
||||
time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
|
||||
});
|
||||
if (!frame) {
|
||||
return null;
|
||||
@@ -421,7 +421,7 @@ async function resolveBackdropSource({
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: node.params.mediaId,
|
||||
file: node.params.file,
|
||||
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
|
||||
time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
|
||||
});
|
||||
if (!frame) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV27ToV28 } from "../transformers/v27-to-v28";
|
||||
|
||||
describe("V27 to V28 Migration", () => {
|
||||
test("rounds persisted media-time floats back to integer ticks", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v27-float-time",
|
||||
version: 27,
|
||||
metadata: {
|
||||
id: "project-v27-float-time",
|
||||
name: "Project",
|
||||
duration: 2_152_466.3677130044,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: { numerator: 30, denominator: 1 },
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
timelineViewState: {
|
||||
zoomLevel: 1.25,
|
||||
scrollLeft: 120,
|
||||
playheadTime: 301_234.8,
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-1",
|
||||
name: "Scene 1",
|
||||
isMain: true,
|
||||
bookmarks: [
|
||||
{
|
||||
time: 120_000.4,
|
||||
duration: 60_000.6,
|
||||
note: "Marker",
|
||||
color: "#ff0000",
|
||||
},
|
||||
],
|
||||
tracks: {
|
||||
main: {
|
||||
id: "main-track",
|
||||
type: "video",
|
||||
name: "Main",
|
||||
muted: false,
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "element-1",
|
||||
type: "video",
|
||||
name: "Clip",
|
||||
startTime: 300_000.49,
|
||||
duration: 2_152_466.3677130044,
|
||||
trimStart: 30_000.2,
|
||||
trimEnd: 15_000.7,
|
||||
sourceDuration: 2_197_467.6,
|
||||
mediaId: "media-1",
|
||||
transform: {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
animations: {
|
||||
channels: {
|
||||
opacity: {
|
||||
kind: "scalar",
|
||||
keys: [
|
||||
{
|
||||
id: "key-1",
|
||||
time: 1_000.6,
|
||||
value: 1,
|
||||
segmentToNext: "bezier",
|
||||
tangentMode: "flat",
|
||||
leftHandle: { dt: -120.6, dv: 0 },
|
||||
rightHandle: { dt: 60.4, dv: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
overlay: [],
|
||||
audio: [],
|
||||
},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(28);
|
||||
|
||||
const metadata = result.project.metadata as Record<string, unknown>;
|
||||
expect(metadata.duration).toBe(2_152_466);
|
||||
|
||||
const timelineViewState = result.project.timelineViewState as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(timelineViewState.playheadTime).toBe(301_235);
|
||||
expect(timelineViewState.zoomLevel).toBe(1.25);
|
||||
expect(timelineViewState.scrollLeft).toBe(120);
|
||||
|
||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
||||
const scene = scenes[0];
|
||||
expect(scene.bookmarks).toEqual([
|
||||
{
|
||||
time: 120_000,
|
||||
duration: 60_001,
|
||||
note: "Marker",
|
||||
color: "#ff0000",
|
||||
},
|
||||
]);
|
||||
|
||||
const tracks = scene.tracks as Record<string, unknown>;
|
||||
const mainTrack = tracks.main as Record<string, unknown>;
|
||||
const elements = mainTrack.elements as Array<Record<string, unknown>>;
|
||||
const element = elements[0];
|
||||
expect(element.startTime).toBe(300_000);
|
||||
expect(element.duration).toBe(2_152_466);
|
||||
expect(element.trimStart).toBe(30_000);
|
||||
expect(element.trimEnd).toBe(15_001);
|
||||
expect(element.sourceDuration).toBe(2_197_468);
|
||||
|
||||
const animations = element.animations as Record<string, unknown>;
|
||||
const channels = animations.channels as Record<string, Record<string, unknown>>;
|
||||
const opacityChannel = channels.opacity;
|
||||
expect(opacityChannel.keys).toEqual([
|
||||
{
|
||||
id: "key-1",
|
||||
time: 1_001,
|
||||
value: 1,
|
||||
segmentToNext: "bezier",
|
||||
tangentMode: "flat",
|
||||
leftHandle: { dt: -121, dv: 0 },
|
||||
rightHandle: { dt: 60, dv: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps already-integer media-time values unchanged", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v27-integer-time",
|
||||
version: 27,
|
||||
metadata: {
|
||||
id: "project-v27-integer-time",
|
||||
name: "Project",
|
||||
duration: 120_000,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
timelineViewState: {
|
||||
zoomLevel: 2,
|
||||
scrollLeft: 300,
|
||||
playheadTime: 30_000,
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-1",
|
||||
bookmarks: [{ time: 60_000, duration: 15_000 }],
|
||||
tracks: {
|
||||
main: {
|
||||
id: "main-track",
|
||||
type: "video",
|
||||
name: "Main",
|
||||
muted: false,
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "element-1",
|
||||
type: "video",
|
||||
name: "Clip",
|
||||
startTime: 10_000,
|
||||
duration: 20_000,
|
||||
trimStart: 1_000,
|
||||
trimEnd: 2_000,
|
||||
sourceDuration: 23_000,
|
||||
mediaId: "media-1",
|
||||
transform: {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
overlay: [],
|
||||
audio: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(28);
|
||||
|
||||
const metadata = result.project.metadata as Record<string, unknown>;
|
||||
expect(metadata.duration).toBe(120_000);
|
||||
|
||||
const timelineViewState = result.project.timelineViewState as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(timelineViewState).toEqual({
|
||||
zoomLevel: 2,
|
||||
scrollLeft: 300,
|
||||
playheadTime: 30_000,
|
||||
});
|
||||
|
||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
||||
const scene = scenes[0];
|
||||
expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]);
|
||||
|
||||
const tracks = scene.tracks as Record<string, unknown>;
|
||||
const mainTrack = tracks.main as Record<string, unknown>;
|
||||
const element = (mainTrack.elements as Array<Record<string, unknown>>)[0];
|
||||
expect(element.startTime).toBe(10_000);
|
||||
expect(element.duration).toBe(20_000);
|
||||
expect(element.trimStart).toBe(1_000);
|
||||
expect(element.trimEnd).toBe(2_000);
|
||||
expect(element.sourceDuration).toBe(23_000);
|
||||
});
|
||||
|
||||
test("skips projects already on v28", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v28",
|
||||
version: 28,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v28");
|
||||
});
|
||||
|
||||
test("skips projects not on v27", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v26",
|
||||
version: 26,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("not v27");
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,11 @@ import { V23toV24Migration } from "./v23-to-v24";
|
||||
import { V24toV25Migration } from "./v24-to-v25";
|
||||
import { V25toV26Migration } from "./v25-to-v26";
|
||||
import { V26toV27Migration } from "./v26-to-v27";
|
||||
import { V27toV28Migration } from "./v27-to-v28";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 27;
|
||||
export const CURRENT_PROJECT_VERSION = 28;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -59,4 +60,5 @@ export const migrations = [
|
||||
new V24toV25Migration(),
|
||||
new V25toV26Migration(),
|
||||
new V26toV27Migration(),
|
||||
new V27toV28Migration(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV27ToV28({
|
||||
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 >= 28) {
|
||||
return { project, skipped: true, reason: "already v28" };
|
||||
}
|
||||
if (version !== 27) {
|
||||
return { project, skipped: true, reason: "not v27" };
|
||||
}
|
||||
|
||||
return {
|
||||
project: {
|
||||
...migrateProject({ project }),
|
||||
version: 28,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProject({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const nextProject = { ...project };
|
||||
|
||||
if (isRecord(project.metadata)) {
|
||||
nextProject.metadata = migrateTimeFields({
|
||||
record: project.metadata,
|
||||
keys: ["duration"],
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(project.timelineViewState)) {
|
||||
nextProject.timelineViewState = migrateTimeFields({
|
||||
record: project.timelineViewState,
|
||||
keys: ["playheadTime"],
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(project.scenes)) {
|
||||
nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
|
||||
}
|
||||
|
||||
return nextProject;
|
||||
}
|
||||
|
||||
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 (isRecord(scene.tracks)) {
|
||||
nextScene.tracks = migrateTracks({ tracks: scene.tracks });
|
||||
}
|
||||
|
||||
return nextScene;
|
||||
}
|
||||
|
||||
function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
|
||||
const nextTracks = { ...tracks };
|
||||
|
||||
if (isRecord(tracks.main)) {
|
||||
nextTracks.main = migrateTrack({ track: tracks.main });
|
||||
}
|
||||
|
||||
if (Array.isArray(tracks.overlay)) {
|
||||
nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
|
||||
}
|
||||
|
||||
if (Array.isArray(tracks.audio)) {
|
||||
nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
|
||||
}
|
||||
|
||||
return nextTracks;
|
||||
}
|
||||
|
||||
function migrateTrack({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track) || !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) || !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 = migrateTimeFields({
|
||||
record: keyframe.leftHandle,
|
||||
keys: ["dt"],
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(keyframe.rightHandle)) {
|
||||
nextKeyframe.rightHandle = migrateTimeFields({
|
||||
record: keyframe.rightHandle,
|
||||
keys: ["dt"],
|
||||
});
|
||||
}
|
||||
|
||||
return nextKeyframe;
|
||||
}
|
||||
|
||||
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] = normalizeMediaTimeValue({ value: record[key] });
|
||||
}
|
||||
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
function normalizeMediaTimeValue({ value }: { value: unknown }): unknown {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return roundMediaTime({ time: value });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StorageMigration, type StorageMigrationRunArgs } from "./base";
|
||||
import type { MigrationResult, ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV27ToV28 } from "./transformers/v27-to-v28";
|
||||
|
||||
export class V27toV28Migration extends StorageMigration {
|
||||
from = 27;
|
||||
to = 28;
|
||||
|
||||
async run({
|
||||
project,
|
||||
}: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
|
||||
return transformProjectV27ToV28({ project });
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,15 @@ import {
|
||||
runStorageMigrations,
|
||||
} from "@/services/storage/migrations";
|
||||
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
|
||||
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((item): Bookmark | null => {
|
||||
if (typeof item === "number") return { time: item };
|
||||
if (typeof item === "number") {
|
||||
return { time: roundMediaTime({ time: item }) };
|
||||
}
|
||||
const obj = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj !== "object" ||
|
||||
@@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
time: obj.time,
|
||||
time: roundMediaTime({ time: obj.time }),
|
||||
...(typeof obj.note === "string" && { note: obj.note }),
|
||||
...(typeof obj.color === "string" && { color: obj.color }),
|
||||
...(typeof obj.duration === "number" && { duration: obj.duration }),
|
||||
...(typeof obj.duration === "number" && {
|
||||
duration: roundMediaTime({ time: obj.duration }),
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((b): b is Bookmark => b !== null);
|
||||
@@ -198,9 +203,11 @@ class StorageService {
|
||||
id: serializedProject.metadata.id,
|
||||
name: serializedProject.metadata.name,
|
||||
thumbnail: serializedProject.metadata.thumbnail,
|
||||
duration:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes }),
|
||||
duration: roundMediaTime({
|
||||
time:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes }),
|
||||
}),
|
||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||
},
|
||||
@@ -253,11 +260,13 @@ class StorageService {
|
||||
id: serializedProject.metadata.id,
|
||||
name: serializedProject.metadata.name,
|
||||
thumbnail: serializedProject.metadata.thumbnail,
|
||||
duration:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({
|
||||
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
|
||||
}),
|
||||
duration: roundMediaTime({
|
||||
time:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({
|
||||
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
|
||||
}),
|
||||
}),
|
||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user