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

View File

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

View File

@ -9,6 +9,8 @@ import type {
RetimeConfig, RetimeConfig,
} from "@/timeline"; } from "@/timeline";
import { calculateTotalDuration } from "@/timeline"; import { calculateTotalDuration } from "@/timeline";
import { normalizeSceneTracks } from "@/timeline/normalize";
import { quantizeMediaTime } from "@/wasm/ticks";
import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import { findTrackInSceneTracks } from "@/timeline/track-element-update";
import { import {
canElementBeHidden, canElementBeHidden,
@ -210,7 +212,7 @@ export class TimelineManager {
} }
getLastFrameTime(): number { getLastFrameTime(): number {
const duration = this.getTotalDuration(); const duration = quantizeMediaTime({ time: this.getTotalDuration() });
const fps = this.editor.project.getActive()?.settings.fps; const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration; if (!fps || duration <= 0) return duration;
return lastFrameTime({ duration, rate: fps }) ?? duration; return lastFrameTime({ duration, rate: fps }) ?? duration;
@ -926,7 +928,9 @@ export class TimelineManager {
updateTracks(newTracks: SceneTracks): void { updateTracks(newTracks: SceneTracks): void {
this.previewOverlay.clear(); this.previewOverlay.clear();
this.previewTracks = null; this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks }); this.editor.scenes.updateSceneTracks({
tracks: normalizeSceneTracks({ tracks: newTracks }),
});
this.notify(); 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 { V24toV25Migration } from "./v24-to-v25";
import { V25toV26Migration } from "./v25-to-v26"; import { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27"; import { V26toV27Migration } from "./v26-to-v27";
import { V27toV28Migration } from "./v27-to-v28";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 27; export const CURRENT_PROJECT_VERSION = 28;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@ -59,4 +60,5 @@ export const migrations = [
new V24toV25Migration(), new V24toV25Migration(),
new V25toV26Migration(), new V25toV26Migration(),
new V26toV27Migration(), 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 type { TProject, TProjectMetadata } from "@/project/types";
import {
normalizeBookmark,
normalizeScene,
normalizeSceneTracks,
normalizeTimelineValue,
} from "@/timeline/normalize";
import { getProjectDurationFromScenes } from "@/timeline/scenes"; import { getProjectDurationFromScenes } from "@/timeline/scenes";
import type { MediaAsset } from "@/media/types"; import type { MediaAsset } from "@/media/types";
import { IndexedDBAdapter } from "./indexeddb-adapter"; import { IndexedDBAdapter } from "./indexeddb-adapter";
@ -127,25 +133,30 @@ class StorageService {
} }
async saveProject({ project }: { project: TProject }): Promise<void> { async saveProject({ project }: { project: TProject }): Promise<void> {
const normalizedScenes = project.scenes.map((scene) =>
normalizeScene({ scene }),
);
const duration = const duration =
project.metadata.duration ?? project.metadata.duration ??
getProjectDurationFromScenes({ scenes: project.scenes }); getProjectDurationFromScenes({ scenes: normalizedScenes });
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ const serializedScenes: SerializedScene[] = normalizedScenes.map(
id: scene.id, (scene) => ({
name: scene.name, id: scene.id,
isMain: scene.isMain, name: scene.name,
tracks: this.stripAudioBuffers({ tracks: scene.tracks }), isMain: scene.isMain,
bookmarks: scene.bookmarks, tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
createdAt: scene.createdAt.toISOString(), bookmarks: scene.bookmarks,
updatedAt: scene.updatedAt.toISOString(), createdAt: scene.createdAt.toISOString(),
})); updatedAt: scene.updatedAt.toISOString(),
}),
);
const serializedProject: SerializedProject = { const serializedProject: SerializedProject = {
metadata: { metadata: {
id: project.metadata.id, id: project.metadata.id,
name: project.metadata.name, name: project.metadata.name,
thumbnail: project.metadata.thumbnail, thumbnail: project.metadata.thumbnail,
duration, duration: normalizeTimelineValue({ value: duration }),
createdAt: project.metadata.createdAt.toISOString(), createdAt: project.metadata.createdAt.toISOString(),
updatedAt: project.metadata.updatedAt.toISOString(), updatedAt: project.metadata.updatedAt.toISOString(),
}, },
@ -153,7 +164,14 @@ class StorageService {
currentSceneId: project.currentSceneId, currentSceneId: project.currentSceneId,
settings: project.settings, settings: project.settings,
version: project.version, 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); await this.projectsAdapter.set(project.metadata.id, serializedProject);
@ -187,8 +205,10 @@ class StorageService {
id: scene.id, id: scene.id,
name: scene.name, name: scene.name,
isMain: scene.isMain, isMain: scene.isMain,
tracks: scene.tracks, tracks: normalizeSceneTracks({ tracks: scene.tracks }),
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }), bookmarks: normalizeBookmarks({ raw: scene.bookmarks }).map(
(bookmark) => normalizeBookmark({ bookmark }),
),
createdAt: new Date(scene.createdAt), createdAt: new Date(scene.createdAt),
updatedAt: new Date(scene.updatedAt), updatedAt: new Date(scene.updatedAt),
})) ?? []; })) ?? [];
@ -198,9 +218,11 @@ class StorageService {
id: serializedProject.metadata.id, id: serializedProject.metadata.id,
name: serializedProject.metadata.name, name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail, thumbnail: serializedProject.metadata.thumbnail,
duration: duration: normalizeTimelineValue({
serializedProject.metadata.duration ?? value:
getProjectDurationFromScenes({ scenes }), serializedProject.metadata.duration ??
getProjectDurationFromScenes({ scenes }),
}),
createdAt: new Date(serializedProject.metadata.createdAt), createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt), updatedAt: new Date(serializedProject.metadata.updatedAt),
}, },
@ -208,7 +230,14 @@ class StorageService {
currentSceneId: serializedProject.currentSceneId || "", currentSceneId: serializedProject.currentSceneId || "",
settings: serializedProject.settings, settings: serializedProject.settings,
version: serializedProject.version, version: serializedProject.version,
timelineViewState: serializedProject.timelineViewState, timelineViewState: serializedProject.timelineViewState
? {
...serializedProject.timelineViewState,
playheadTime: normalizeTimelineValue({
value: serializedProject.timelineViewState.playheadTime,
}),
}
: undefined,
}; };
return { project }; return { project };

View File

@ -1,29 +1,30 @@
import type { SceneTracks } from "./types"; import type { SceneTracks } from "./types";
import { normalizeTimelineValue } from "./normalize";
export * from "./types";
export * from "./drag"; export * from "./types";
export * from "./track-capabilities"; export * from "./drag";
export * from "./track-element-update"; export * from "./track-capabilities";
export * from "./element-utils"; export * from "./track-element-update";
export * from "./audio-separation"; export * from "./element-utils";
export * from "./zoom-utils"; export * from "./audio-separation";
export * from "./ruler-utils"; export * from "./zoom-utils";
export * from "./pixel-utils"; export * from "./ruler-utils";
export * from "./pixel-utils";
export function calculateTotalDuration({
tracks, export function calculateTotalDuration({
}: { tracks,
tracks: SceneTracks; }: {
}): number { tracks: SceneTracks;
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; }): number {
if (orderedTracks.length === 0) return 0; 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 trackEndTimes = orderedTracks.map((track) =>
const elementEnd = element.startTime + element.duration; track.elements.reduce((maxEnd, element) => {
return Math.max(maxEnd, elementEnd); const elementEnd = element.startTime + element.duration;
}, 0), return Math.max(maxEnd, elementEnd);
); }, 0),
);
return Math.max(...trackEndTimes, 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"; } from "@/retime";
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline"; import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
import { isRetimableElement } from "@/timeline"; import { isRetimableElement } from "@/timeline";
import {
normalizeTimelineElement,
normalizeTimelineValue,
} from "@/timeline/normalize";
type ElementUpdateField = keyof TimelineElement | string; type ElementUpdateField = keyof TimelineElement | string;
@ -70,7 +74,7 @@ const deriveRules: ElementUpdateRule[] = [
element: { element: {
...element, ...element,
retime: nextRetime, retime: nextRetime,
duration: nextDuration, duration: normalizeTimelineValue({ value: nextDuration }),
}, },
changedFields: ["retime", "duration"], changedFields: ["retime", "duration"],
}; };
@ -82,25 +86,31 @@ const enforceRules: ElementUpdateRule[] = [
{ {
triggers: ["duration"], triggers: ["duration"],
apply: ({ element }) => ({ apply: ({ element }) => ({
element: { element: normalizeTimelineElement({
...element, element: {
animations: clampAnimationsToDuration({ ...element,
animations: element.animations, animations: clampAnimationsToDuration({
duration: element.duration, animations: element.animations,
}), duration: element.duration,
}, }),
},
}),
}), }),
}, },
{ {
triggers: ["startTime"], triggers: ["startTime"],
apply: ({ element, context }) => { 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) { if (context.trackId !== context.tracks.main.id) {
return { return {
element: { element: normalizeTimelineElement({
...element, element: {
startTime: requestedStartTime, ...element,
}, startTime: requestedStartTime,
},
}),
}; };
} }
@ -114,13 +124,16 @@ const enforceRules: ElementUpdateRule[] = [
}, null); }, null);
return { return {
element: { element: normalizeTimelineElement({
...element, element: {
startTime: ...element,
!earliestElement || requestedStartTime <= earliestElement.startTime startTime:
? 0 !earliestElement ||
: requestedStartTime, requestedStartTime <= earliestElement.startTime
}, ? 0
: requestedStartTime,
},
}),
}; };
}, },
}, },
@ -136,9 +149,7 @@ export function applyElementUpdate({
context: ElementUpdateContext; context: ElementUpdateContext;
}): TimelineElement { }): TimelineElement {
let nextElement = { ...element, ...patch } as TimelineElement; let nextElement = { ...element, ...patch } as TimelineElement;
const changedFields = new Set( const changedFields = new Set(Object.keys(patch) as ElementUpdateField[]);
Object.keys(patch) as ElementUpdateField[],
);
for (const rule of deriveRules) { for (const rule of deriveRules) {
if (!shouldApplyRule({ rule, changedFields })) { if (!shouldApplyRule({ rule, changedFields })) {
@ -170,7 +181,7 @@ export function applyElementUpdate({
}).element; }).element;
} }
return nextElement; return normalizeTimelineElement({ element: nextElement });
} }
function shouldApplyRule({ function shouldApplyRule({

View File

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