diff --git a/apps/web/src/commands/timeline/element/move-elements.ts b/apps/web/src/commands/timeline/element/move-elements.ts index 3ed31d42d..3170a9518 100644 --- a/apps/web/src/commands/timeline/element/move-elements.ts +++ b/apps/web/src/commands/timeline/element/move-elements.ts @@ -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, + }, + }), }); } diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts index 11c317f47..e690ca49e 100644 --- a/apps/web/src/commands/timeline/element/split-elements.ts +++ b/apps/web/src/commands/timeline/element/split-elements.ts @@ -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 } : {}), + }, + }), ]; } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index dbc3332b8..958199766 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -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(); } } diff --git a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts new file mode 100644 index 000000000..e04919f2f --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts @@ -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; + 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>)[0]; + expect(scene.bookmarks).toEqual([{ time: 4_052_374, duration: 18 }]); + + const tracks = scene.tracks as Record; + const mainTrack = tracks.main as Record; + const element = (mainTrack.elements as Array>)[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; + const channels = animations.channels as Record< + string, + Record + >; + 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, + }, + }, + ], + }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index b290b7130..150da7c4e 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -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(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts new file mode 100644 index 000000000..749d30aa2 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts @@ -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 { + 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); +} diff --git a/apps/web/src/services/storage/migrations/v27-to-v28.ts b/apps/web/src/services/storage/migrations/v27-to-v28.ts new file mode 100644 index 000000000..aaec62564 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v27-to-v28.ts @@ -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> { + return transformProjectV27ToV28({ project }); + } +} diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index 858198eb9..b3ab22b23 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -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 { + 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 }; diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts index 5ee079b1b..e8eeff63d 100644 --- a/apps/web/src/timeline/index.ts +++ b/apps/web/src/timeline/index.ts @@ -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) }); +} diff --git a/apps/web/src/timeline/normalize.ts b/apps/web/src/timeline/normalize.ts new file mode 100644 index 000000000..dc7cfe7d1 --- /dev/null +++ b/apps/web/src/timeline/normalize.ts @@ -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({ + 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; +}): NonNullable { + 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({ + track, +}: { + track: TTrack; +}): TTrack { + return { + ...track, + elements: track.elements.map((element) => + normalizeTimelineElement({ element }), + ) as TTrack["elements"], + }; +} + +function normalizeAnimationKeyframe({ + 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; +} diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts index beacf897f..0b25f5d57 100644 --- a/apps/web/src/timeline/update-pipeline.ts +++ b/apps/web/src/timeline/update-pipeline.ts @@ -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({ diff --git a/apps/web/src/wasm/ticks.ts b/apps/web/src/wasm/ticks.ts index ababe6b3d..5301cb07f 100644 --- a/apps/web/src/wasm/ticks.ts +++ b/apps/web/src/wasm/ticks.ts @@ -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; +}