Fix fractional timeline ticks from clip retime

Co-authored-by: Maze <mazeincoding@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-25 15:47:38 +00:00
parent f1b45b4328
commit 9cf858c0c2
No known key found for this signature in database
12 changed files with 556 additions and 114 deletions

View File

@ -13,6 +13,7 @@ import type {
PlannedElementMove,
PlannedTrackCreation,
} from "@/timeline/group-move";
import { normalizeTimelineElement } from "@/timeline/normalize";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
export class MoveElementCommand extends Command {
@ -81,8 +82,12 @@ export class MoveElementCommand extends Command {
}
movedElementsById.set(move.elementId, {
...sourceElement,
startTime: move.newStartTime,
...normalizeTimelineElement({
element: {
...sourceElement,
startTime: move.newStartTime,
},
}),
});
}

View File

@ -9,6 +9,10 @@ import { EditorCore } from "@/core";
import { isRetimableElement } from "@/timeline";
import { splitAnimationsAtTime } from "@/animation";
import { getSourceSpanAtClipTime } from "@/retime";
import {
normalizeTimelineElement,
normalizeTimelineValue,
} from "@/timeline/normalize";
export class SplitElementsCommand extends Command {
private savedState: SceneTracks | null = null;
@ -73,9 +77,13 @@ export class SplitElementsCommand extends Command {
return [element];
}
const relativeTime = this.splitTime - element.startTime;
const relativeTime = normalizeTimelineValue({
value: this.splitTime - element.startTime,
});
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
const rightVisibleDuration = normalizeTimelineValue({
value: element.duration - relativeTime,
});
const retimeRef = isRetimableElement(element)
? element.retime
: undefined;
@ -97,14 +105,16 @@ export class SplitElementsCommand extends Command {
if (this.retainSide === "left") {
splitResult = [
{
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
normalizeTimelineElement({
element: {
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
}),
];
} else if (this.retainSide === "right") {
const newId = generateUUID();
@ -113,16 +123,18 @@ export class SplitElementsCommand extends Command {
elementId: newId,
});
splitResult = [
{
...element,
id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
normalizeTimelineElement({
element: {
...element,
id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
}),
];
} else {
// "both" - split into two pieces
@ -132,24 +144,28 @@ export class SplitElementsCommand extends Command {
elementId: secondElementId,
});
splitResult = [
{
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
{
...element,
id: secondElementId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
normalizeTimelineElement({
element: {
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
}),
normalizeTimelineElement({
element: {
...element,
id: secondElementId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
}),
];
}

View File

@ -9,6 +9,8 @@ import type {
RetimeConfig,
} from "@/timeline";
import { calculateTotalDuration } from "@/timeline";
import { normalizeSceneTracks } from "@/timeline/normalize";
import { quantizeMediaTime } from "@/wasm/ticks";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
import {
canElementBeHidden,
@ -210,7 +212,7 @@ export class TimelineManager {
}
getLastFrameTime(): number {
const duration = this.getTotalDuration();
const duration = quantizeMediaTime({ time: this.getTotalDuration() });
const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration;
return lastFrameTime({ duration, rate: fps }) ?? duration;
@ -926,7 +928,9 @@ export class TimelineManager {
updateTracks(newTracks: SceneTracks): void {
this.previewOverlay.clear();
this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.editor.scenes.updateSceneTracks({
tracks: normalizeSceneTracks({ tracks: newTracks }),
});
this.notify();
}
}

View File

@ -0,0 +1,151 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV27ToV28 } from "../transformers/v27-to-v28";
describe("V27 to V28 Migration", () => {
test("rounds persisted fractional media times to integer ticks", () => {
const result = transformProjectV27ToV28({
project: {
id: "project-v27-retime",
version: 27,
metadata: {
id: "project-v27-retime",
name: "Project",
duration: 4052374.193548387,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
timelineViewState: {
zoomLevel: 1,
scrollLeft: 120,
playheadTime: 4052374.193548387,
},
scenes: [
{
id: "scene-1",
name: "Scene",
isMain: true,
bookmarks: [{ time: 4052374.193548387, duration: 17.9 }],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
tracks: {
main: {
id: "track-1",
name: "Main",
type: "video",
muted: false,
hidden: false,
elements: [
{
id: "element-1",
type: "video",
name: "Clip",
startTime: 10.4,
duration: 290322.5806451613,
trimStart: 30.5,
trimEnd: 11.2,
sourceDuration: 290364.2806451613,
retime: {
rate: 1.24,
},
transform: {
position: { x: 0, y: 0 },
scaleX: 1,
scaleY: 1,
rotate: 0,
},
opacity: 1,
animations: {
bindings: {
opacity: {
path: "opacity",
kind: "number",
components: [
{
key: "value",
channelId: "opacity:value",
},
],
},
},
channels: {
"opacity:value": {
kind: "scalar",
keys: [
{
id: "key-1",
time: 120.75,
value: 1,
segmentToNext: "bezier",
tangentMode: "flat",
rightHandle: {
dt: 15.25,
dv: 0.2,
},
},
],
},
},
},
},
],
},
overlay: [],
audio: [],
},
},
],
settings: {
fps: { numerator: 30, denominator: 1 },
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(28);
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.duration).toBe(4_052_374);
const timelineViewState = result.project.timelineViewState as Record<
string,
unknown
>;
expect(timelineViewState.playheadTime).toBe(4_052_374);
const scene = (result.project.scenes as Array<Record<string, unknown>>)[0];
expect(scene.bookmarks).toEqual([{ time: 4_052_374, duration: 18 }]);
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);
expect(element.duration).toBe(290_323);
expect(element.trimStart).toBe(31);
expect(element.trimEnd).toBe(11);
expect(element.sourceDuration).toBe(290_364);
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: 121,
value: 1,
segmentToNext: "bezier",
tangentMode: "flat",
rightHandle: {
dt: 15,
dv: 0.2,
},
},
],
});
});
});

View File

@ -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(),
];

View File

@ -0,0 +1,69 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
const TIMELINE_KEYS = new Set([
"duration",
"startTime",
"trimStart",
"trimEnd",
"sourceDuration",
"time",
"playheadTime",
"dt",
]);
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: {
...(normalizeTimelineRecord({ value: project }) as ProjectRecord),
version: 28,
},
skipped: false,
};
}
function normalizeTimelineRecord({ value }: { value: unknown }): unknown {
if (Array.isArray(value)) {
return value.map((item) => normalizeTimelineRecord({ value: item }));
}
if (!isRecord(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [
key,
TIMELINE_KEYS.has(key)
? normalizeTimelineScalar({ value: entryValue })
: normalizeTimelineRecord({ value: entryValue }),
]),
);
}
function normalizeTimelineScalar({ value }: { value: unknown }): unknown {
if (typeof value !== "number" || !Number.isFinite(value)) {
return value;
}
return Math.round(value);
}

View File

@ -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 });
}
}

View File

@ -1,4 +1,10 @@
import type { TProject, TProjectMetadata } from "@/project/types";
import {
normalizeBookmark,
normalizeScene,
normalizeSceneTracks,
normalizeTimelineValue,
} from "@/timeline/normalize";
import { getProjectDurationFromScenes } from "@/timeline/scenes";
import type { MediaAsset } from "@/media/types";
import { IndexedDBAdapter } from "./indexeddb-adapter";
@ -127,25 +133,30 @@ class StorageService {
}
async saveProject({ project }: { project: TProject }): Promise<void> {
const normalizedScenes = project.scenes.map((scene) =>
normalizeScene({ scene }),
);
const duration =
project.metadata.duration ??
getProjectDurationFromScenes({ scenes: project.scenes });
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
bookmarks: scene.bookmarks,
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),
}));
getProjectDurationFromScenes({ scenes: normalizedScenes });
const serializedScenes: SerializedScene[] = normalizedScenes.map(
(scene) => ({
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
bookmarks: scene.bookmarks,
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),
}),
);
const serializedProject: SerializedProject = {
metadata: {
id: project.metadata.id,
name: project.metadata.name,
thumbnail: project.metadata.thumbnail,
duration,
duration: normalizeTimelineValue({ value: duration }),
createdAt: project.metadata.createdAt.toISOString(),
updatedAt: project.metadata.updatedAt.toISOString(),
},
@ -153,7 +164,14 @@ class StorageService {
currentSceneId: project.currentSceneId,
settings: project.settings,
version: project.version,
timelineViewState: project.timelineViewState,
timelineViewState: project.timelineViewState
? {
...project.timelineViewState,
playheadTime: normalizeTimelineValue({
value: project.timelineViewState.playheadTime,
}),
}
: undefined,
};
await this.projectsAdapter.set(project.metadata.id, serializedProject);
@ -187,8 +205,10 @@ class StorageService {
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: scene.tracks,
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
tracks: normalizeSceneTracks({ tracks: scene.tracks }),
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }).map(
(bookmark) => normalizeBookmark({ bookmark }),
),
createdAt: new Date(scene.createdAt),
updatedAt: new Date(scene.updatedAt),
})) ?? [];
@ -198,9 +218,11 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
duration:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({ scenes }),
duration: normalizeTimelineValue({
value:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({ scenes }),
}),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
},
@ -208,7 +230,14 @@ class StorageService {
currentSceneId: serializedProject.currentSceneId || "",
settings: serializedProject.settings,
version: serializedProject.version,
timelineViewState: serializedProject.timelineViewState,
timelineViewState: serializedProject.timelineViewState
? {
...serializedProject.timelineViewState,
playheadTime: normalizeTimelineValue({
value: serializedProject.timelineViewState.playheadTime,
}),
}
: undefined,
};
return { project };

View File

@ -1,29 +1,30 @@
import type { SceneTracks } from "./types";
export * from "./types";
export * from "./drag";
export * from "./track-capabilities";
export * from "./track-element-update";
export * from "./element-utils";
export * from "./audio-separation";
export * from "./zoom-utils";
export * from "./ruler-utils";
export * from "./pixel-utils";
export function calculateTotalDuration({
tracks,
}: {
tracks: SceneTracks;
}): number {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
if (orderedTracks.length === 0) return 0;
const trackEndTimes = orderedTracks.map((track) =>
track.elements.reduce((maxEnd, element) => {
const elementEnd = element.startTime + element.duration;
return Math.max(maxEnd, elementEnd);
}, 0),
);
return Math.max(...trackEndTimes, 0);
}
import type { SceneTracks } from "./types";
import { normalizeTimelineValue } from "./normalize";
export * from "./types";
export * from "./drag";
export * from "./track-capabilities";
export * from "./track-element-update";
export * from "./element-utils";
export * from "./audio-separation";
export * from "./zoom-utils";
export * from "./ruler-utils";
export * from "./pixel-utils";
export function calculateTotalDuration({
tracks,
}: {
tracks: SceneTracks;
}): number {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
if (orderedTracks.length === 0) return 0;
const trackEndTimes = orderedTracks.map((track) =>
track.elements.reduce((maxEnd, element) => {
const elementEnd = element.startTime + element.duration;
return Math.max(maxEnd, elementEnd);
}, 0),
);
return normalizeTimelineValue({ value: Math.max(...trackEndTimes, 0) });
}

View File

@ -0,0 +1,136 @@
import type { Bookmark, SceneTracks, TScene, TimelineElement } from "./types";
export function normalizeTimelineValue({ value }: { value: number }): number {
return Number.isFinite(value) ? Math.round(value) : value;
}
export function normalizeBookmark({
bookmark,
}: {
bookmark: Bookmark;
}): Bookmark {
return {
...bookmark,
time: normalizeTimelineValue({ value: bookmark.time }),
...(bookmark.duration !== undefined && {
duration: normalizeTimelineValue({ value: bookmark.duration }),
}),
};
}
export function normalizeTimelineElement<TElement extends TimelineElement>({
element,
}: {
element: TElement;
}): TElement {
const nextElement = {
...element,
startTime: normalizeTimelineValue({ value: element.startTime }),
duration: normalizeTimelineValue({ value: element.duration }),
trimStart: normalizeTimelineValue({ value: element.trimStart }),
trimEnd: normalizeTimelineValue({ value: element.trimEnd }),
...(typeof element.sourceDuration === "number" && {
sourceDuration: normalizeTimelineValue({ value: element.sourceDuration }),
}),
} as TElement;
if ("animations" in nextElement && nextElement.animations) {
nextElement.animations = normalizeAnimations({
animations: nextElement.animations,
});
}
return nextElement;
}
export function normalizeSceneTracks({
tracks,
}: {
tracks: SceneTracks;
}): SceneTracks {
return {
overlay: tracks.overlay.map((track) => normalizeTrack({ track })),
main: normalizeTrack({ track: tracks.main }),
audio: tracks.audio.map((track) => normalizeTrack({ track })),
};
}
export function normalizeScene({ scene }: { scene: TScene }): TScene {
return {
...scene,
tracks: normalizeSceneTracks({ tracks: scene.tracks }),
bookmarks: scene.bookmarks.map((bookmark) =>
normalizeBookmark({ bookmark }),
),
};
}
function normalizeAnimations({
animations,
}: {
animations: NonNullable<TimelineElement["animations"]>;
}): NonNullable<TimelineElement["animations"]> {
return {
...animations,
channels: Object.fromEntries(
Object.entries(animations.channels).map(([channelId, channel]) => {
if (!channel) {
return [channelId, channel];
}
return [
channelId,
{
...channel,
keys: channel.keys.map((keyframe) =>
normalizeAnimationKeyframe({ keyframe }),
),
},
];
}),
) as typeof animations.channels,
};
}
function normalizeTrack<TTrack extends { elements: TimelineElement[] }>({
track,
}: {
track: TTrack;
}): TTrack {
return {
...track,
elements: track.elements.map((element) =>
normalizeTimelineElement({ element }),
) as TTrack["elements"],
};
}
function normalizeAnimationKeyframe<TKeyframe extends { time: number }>({
keyframe,
}: {
keyframe: TKeyframe;
}): TKeyframe {
const nextKeyframe = {
...keyframe,
time: normalizeTimelineValue({ value: keyframe.time }),
} as TKeyframe & {
leftHandle?: { dt: number };
rightHandle?: { dt: number };
};
if ("leftHandle" in keyframe && nextKeyframe.leftHandle) {
nextKeyframe.leftHandle = {
...nextKeyframe.leftHandle,
dt: normalizeTimelineValue({ value: nextKeyframe.leftHandle.dt }),
};
}
if ("rightHandle" in keyframe && nextKeyframe.rightHandle) {
nextKeyframe.rightHandle = {
...nextKeyframe.rightHandle,
dt: normalizeTimelineValue({ value: nextKeyframe.rightHandle.dt }),
};
}
return nextKeyframe;
}

View File

@ -6,6 +6,10 @@ import {
} from "@/retime";
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
import { isRetimableElement } from "@/timeline";
import {
normalizeTimelineElement,
normalizeTimelineValue,
} from "@/timeline/normalize";
type ElementUpdateField = keyof TimelineElement | string;
@ -70,7 +74,7 @@ const deriveRules: ElementUpdateRule[] = [
element: {
...element,
retime: nextRetime,
duration: nextDuration,
duration: normalizeTimelineValue({ value: nextDuration }),
},
changedFields: ["retime", "duration"],
};
@ -82,25 +86,31 @@ const enforceRules: ElementUpdateRule[] = [
{
triggers: ["duration"],
apply: ({ element }) => ({
element: {
...element,
animations: clampAnimationsToDuration({
animations: element.animations,
duration: element.duration,
}),
},
element: normalizeTimelineElement({
element: {
...element,
animations: clampAnimationsToDuration({
animations: element.animations,
duration: element.duration,
}),
},
}),
}),
},
{
triggers: ["startTime"],
apply: ({ element, context }) => {
const requestedStartTime = Math.max(0, element.startTime);
const requestedStartTime = normalizeTimelineValue({
value: Math.max(0, element.startTime),
});
if (context.trackId !== context.tracks.main.id) {
return {
element: {
...element,
startTime: requestedStartTime,
},
element: normalizeTimelineElement({
element: {
...element,
startTime: requestedStartTime,
},
}),
};
}
@ -114,13 +124,16 @@ const enforceRules: ElementUpdateRule[] = [
}, null);
return {
element: {
...element,
startTime:
!earliestElement || requestedStartTime <= earliestElement.startTime
? 0
: requestedStartTime,
},
element: normalizeTimelineElement({
element: {
...element,
startTime:
!earliestElement ||
requestedStartTime <= earliestElement.startTime
? 0
: requestedStartTime,
},
}),
};
},
},
@ -136,9 +149,7 @@ export function applyElementUpdate({
context: ElementUpdateContext;
}): TimelineElement {
let nextElement = { ...element, ...patch } as TimelineElement;
const changedFields = new Set(
Object.keys(patch) as ElementUpdateField[],
);
const changedFields = new Set(Object.keys(patch) as ElementUpdateField[]);
for (const rule of deriveRules) {
if (!shouldApplyRule({ rule, changedFields })) {
@ -170,7 +181,7 @@ export function applyElementUpdate({
}).element;
}
return nextElement;
return normalizeTimelineElement({ element: nextElement });
}
function shouldApplyRule({

View File

@ -1,3 +1,7 @@
import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
export function quantizeMediaTime({ time }: { time: number }): number {
return Number.isFinite(time) ? Math.round(time) : time;
}