diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index e62a8ce0..5e90a6a1 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -5,7 +5,16 @@ import { useTimelineStore } from "@/timeline/timeline-store"; import { useActionHandler } from "@/actions/use-action-handler"; import { useEditor } from "@/editor/use-editor"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + mediaTimeFromSeconds, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; import { getElementsAtTime, hasMediaId } from "@/timeline"; import { cancelInteraction } from "@/editor/cancel-interaction"; @@ -83,7 +92,7 @@ export function useEditorActions() { if (editor.playback.getIsPlaying()) { editor.playback.toggle(); } - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); @@ -92,11 +101,15 @@ export function useEditorActions() { "seek-forward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -106,8 +119,15 @@ export function useEditorActions() { "seek-backward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -117,15 +137,20 @@ export function useEditorActions() { "frame-step-forward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + ticksPerFrame, + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, ), }); + editor.playback.seek({ + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), + }); }, undefined, ); @@ -134,11 +159,19 @@ export function useEditorActions() { "frame-step-backward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), }); }, undefined, @@ -148,11 +181,15 @@ export function useEditorActions() { "jump-forward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -162,8 +199,15 @@ export function useEditorActions() { "jump-backward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -172,7 +216,7 @@ export function useEditorActions() { useActionHandler( "goto-start", () => { - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts index af411dd1..c947e7dc 100644 --- a/apps/web/src/animation/curve-bridge.ts +++ b/apps/web/src/animation/curve-bridge.ts @@ -7,6 +7,7 @@ import type { NormalizedCubicBezier, ScalarAnimationKey, } from "@/animation/types"; +import { roundMediaTime } from "@/wasm"; const VALUE_EPSILON = 1e-6; @@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({ return { rightHandle: { - dt: spanTime * x1, + dt: roundMediaTime({ time: spanTime * x1 }), dv: effectiveSpanValue * y1, }, leftHandle: { - dt: spanTime * (x2 - 1), + dt: roundMediaTime({ time: spanTime * (x2 - 1) }), dv: effectiveSpanValue * (y2 - 1), }, }; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts index 277bfbb7..2169024a 100644 --- a/apps/web/src/animation/interpolation.ts +++ b/apps/web/src/animation/interpolation.ts @@ -9,6 +9,7 @@ import type { ScalarSegmentType, } from "@/animation/types"; import { clamp } from "@/utils/math"; +import { mediaTime } from "@/wasm"; import { getBezierPoint, getDefaultLeftHandle, @@ -63,9 +64,13 @@ function normalizeRightHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.min(span, Math.max(0, handle.dt)), + dt: mediaTime({ + ticks: Math.min(span, Math.max(0, handle.dt)), + }), dv: handle.dv, }; } @@ -83,9 +88,13 @@ function normalizeLeftHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.max(-span, Math.min(0, handle.dt)), + dt: mediaTime({ + ticks: Math.max(-span, Math.min(0, handle.dt)), + }), dv: handle.dv, }; } diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index 12dc777d..bdfe7c4c 100644 --- a/apps/web/src/animation/keyframes.ts +++ b/apps/web/src/animation/keyframes.ts @@ -35,6 +35,12 @@ import { coerceAnimationValueForProperty, getAnimationPropertyDefinition, } from "./property-registry"; +import { + type MediaTime, + roundMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; function isNearlySameTime({ leftTime, @@ -171,7 +177,7 @@ function createScalarKey({ previousKey, }: { id: string; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; previousKey?: ScalarAnimationKey; @@ -195,7 +201,7 @@ function createDiscreteKey({ value, }: { id: string; - time: number; + time: MediaTime; value: string | boolean; }): DiscreteAnimationKey { return { @@ -241,7 +247,7 @@ function getTargetKeyMetadata({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; + time: MediaTime; keyframeId?: string; }) { const normalizedChannel = @@ -280,7 +286,7 @@ function upsertDiscreteChannelKey({ keyframeId, }: { channel: DiscreteAnimationChannel | undefined; - time: number; + time: MediaTime; value: string | boolean; keyframeId?: string; }): DiscreteAnimationChannel { @@ -337,7 +343,7 @@ function upsertScalarChannelKey({ keyframeId, }: { channel: ScalarAnimationChannel | undefined; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; defaultInterpolation?: AnimationInterpolation; @@ -442,7 +448,7 @@ export function upsertPathKeyframe({ }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -537,7 +543,7 @@ export function upsertElementKeyframe({ }: { animations: ElementAnimations | undefined; propertyPath: AnimationPropertyPath; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -576,7 +582,7 @@ export function upsertKeyframe({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -642,7 +648,7 @@ export function retimeKeyframe({ }: { channel: AnimationChannel | undefined; keyframeId: string; - time: number; + time: MediaTime; }): AnimationChannel | undefined { if (!channel) { return undefined; @@ -887,7 +893,7 @@ export function clampAnimationsToDuration({ duration, }: { animations: ElementAnimations | undefined; - duration: number; + duration: MediaTime; }): ElementAnimations | undefined { if (!animations || duration <= 0) { return undefined; @@ -923,7 +929,7 @@ function splitDiscreteChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: DiscreteAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -939,7 +945,10 @@ function splitDiscreteChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); if (shouldIncludeSplitBoundary) { const hasBoundaryOnLeft = leftKeys.some((key) => @@ -967,7 +976,7 @@ function splitDiscreteChannelAtTime({ rightKeys = [ createDiscreteKey({ id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue as string | boolean, }), ...rightKeys, @@ -993,7 +1002,7 @@ function splitScalarChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: ScalarAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -1009,7 +1018,10 @@ function splitScalarChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); const hasBoundaryOnLeft = leftKeys.some((key) => isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), @@ -1089,7 +1101,7 @@ function splitScalarChannelAtTime({ { ...leftKey, rightHandle: { - dt: q0.x - p0.x, + dt: roundMediaTime({ time: q0.x - p0.x }), dv: q0.y - p0.y, }, }, @@ -1098,7 +1110,7 @@ function splitScalarChannelAtTime({ time: splitTime, value: boundaryValue, leftHandle: { - dt: r0.x - splitPoint.x, + dt: roundMediaTime({ time: r0.x - splitPoint.x }), dv: r0.y - splitPoint.y, }, segmentToNext: leftKey.segmentToNext, @@ -1108,10 +1120,10 @@ function splitScalarChannelAtTime({ rightKeys = [ { id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, rightHandle: { - dt: r1.x - splitPoint.x, + dt: roundMediaTime({ time: r1.x - splitPoint.x }), dv: r1.y - splitPoint.y, }, segmentToNext: "bezier", @@ -1119,9 +1131,9 @@ function splitScalarChannelAtTime({ }, { ...rightKey, - time: rightKey.time - splitTime, + time: subMediaTime({ a: rightKey.time, b: splitTime }), leftHandle: { - dt: q2.x - p3.x, + dt: roundMediaTime({ time: q2.x - p3.x }), dv: q2.y - p3.y, }, }, @@ -1129,7 +1141,7 @@ function splitScalarChannelAtTime({ .filter((key) => key.time > rightKey.time) .map((key) => ({ ...key, - time: key.time - splitTime, + time: subMediaTime({ a: key.time, b: splitTime }), })), ]; } else { @@ -1145,7 +1157,7 @@ function splitScalarChannelAtTime({ rightKeys = [ createScalarKey({ id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, interpolation: getScalarSegmentInterpolation({ segment: leftKey.segmentToNext, @@ -1201,7 +1213,7 @@ export function splitAnimationsAtTime({ shouldIncludeSplitBoundary = true, }: { animations: ElementAnimations | undefined; - splitTime: number; + splitTime: MediaTime; shouldIncludeSplitBoundary?: boolean; }): { leftAnimations: ElementAnimations | undefined; @@ -1317,7 +1329,7 @@ export function retimeElementKeyframe({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): ElementAnimations | undefined { const binding = getBinding({ animations, propertyPath }); if (!binding) { diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index 3afddfa1..e02c8ba8 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,4 +1,5 @@ import type { ParamValues } from "@/params"; +import type { MediaTime } from "@/wasm"; export const ANIMATION_PROPERTY_PATHS = [ "transform.positionX", @@ -77,13 +78,13 @@ export type TangentMode = "auto" | "aligned" | "broken" | "flat"; export type ChannelExtrapolationMode = "hold" | "linear"; export interface CurveHandle { - dt: number; + dt: MediaTime; dv: number; } interface BaseAnimationKeyframe { id: string; - time: number; // relative to element start time + time: MediaTime; // relative to element start time value: TValue; } @@ -209,7 +210,7 @@ export interface ScalarCurveKeyframePatch { export interface ElementKeyframe { propertyPath: AnimationPath; id: string; - time: number; + time: MediaTime; value: AnimationValue; interpolation: AnimationInterpolation; } diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts index ad156e12..cb0c0e65 100644 --- a/apps/web/src/clipboard/handlers/keyframes.ts +++ b/apps/web/src/clipboard/handlers/keyframes.ts @@ -7,6 +7,7 @@ import type { KeyframeClipboardCurvePatch, KeyframeClipboardItem, } from "../types"; +import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm"; function resolveSingleSourceElement({ selectedKeyframes, @@ -80,7 +81,7 @@ function buildClipboardItem({ element: TimelineElement; propertyPath: KeyframeClipboardItem["propertyPath"]; keyframeId: string; -}): (Omit & { time: number }) | null { +}): (Omit & { time: MediaTime }) | null { const keyframe = getKeyframeById({ animations: element.animations, propertyPath, @@ -136,10 +137,11 @@ export const KeyframesClipboardHandler = { } const minTime = Math.min(...rawItems.map((item) => item.time)); + const minTimeMedia = roundMediaTime({ time: minTime }); const items = rawItems .map(({ time, ...item }) => ({ ...item, - timeOffset: time - minTime, + timeOffset: subMediaTime({ a: time, b: minTimeMedia }), })) .sort( (left, right) => diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts index 0d249c9d..562f9ac7 100644 --- a/apps/web/src/clipboard/types.ts +++ b/apps/web/src/clipboard/types.ts @@ -12,6 +12,7 @@ import type { ElementRef, TrackType, } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface ElementClipboardItem { trackId: string; @@ -26,7 +27,7 @@ export interface KeyframeClipboardCurvePatch { export interface KeyframeClipboardItem { propertyPath: AnimationPath; - timeOffset: number; + timeOffset: MediaTime; value: AnimationValue; interpolation: AnimationInterpolation; curvePatches: KeyframeClipboardCurvePatch[]; @@ -61,7 +62,7 @@ export interface PasteContext { editor: EditorCore; selectedElements: ElementRef[]; selectedKeyframes: SelectedKeyframeRef[]; - time: number; + time: MediaTime; } export interface ClipboardHandler { diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts index a1624eb0..f3628b28 100644 --- a/apps/web/src/commands/scene/move-bookmark.ts +++ b/apps/web/src/commands/scene/move-bookmark.ts @@ -3,13 +3,14 @@ import { EditorCore } from "@/core"; import type { TScene } from "@/timeline"; import { updateSceneInArray } from "@/timeline/scenes"; import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class MoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; constructor( - private fromTime: number, - private toTime: number, + private fromTime: MediaTime, + private toTime: MediaTime, ) { super(); } diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts index ea592556..bff5c035 100644 --- a/apps/web/src/commands/scene/remove-bookmark.ts +++ b/apps/web/src/commands/scene/remove-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, removeBookmarkFromArray, } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class RemoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: number = 0; + private frameTime: MediaTime = 0 as MediaTime; - constructor(private time: number) { + constructor(private time: MediaTime) { super(); } diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts index 4193c76e..9f318f80 100644 --- a/apps/web/src/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/commands/scene/toggle-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, toggleBookmarkInArray, } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class ToggleBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: number = 0; + private frameTime: MediaTime = 0 as MediaTime; - constructor(private time: number) { + constructor(private time: MediaTime) { super(); } diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts index 0d6a0193..238bb3de 100644 --- a/apps/web/src/commands/scene/update-bookmark.ts +++ b/apps/web/src/commands/scene/update-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, updateBookmarkInArray, } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class UpdateBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; constructor( - private time: number, + private time: MediaTime, private updates: Partial>, ) { super(); diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts index e05b49fa..c1127921 100644 --- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -10,6 +10,13 @@ import type { KeyframeClipboardItem } from "@/clipboard"; import type { SceneTracks, TimelineElement } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline"; import { generateUUID } from "@/utils/id"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; function pasteKeyframesIntoElement({ element, @@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({ clipboardItems, }: { element: TimelineElement; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }): TimelineElement { let nextElement = element; @@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({ continue; } - const keyframeTime = Math.max( - 0, - Math.min(time + item.timeOffset, nextElement.duration), - ); + const keyframeTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ + a: addMediaTime({ a: time, b: item.timeOffset }), + b: nextElement.duration, + }), + }); const nextAnimations = upsertPathKeyframe({ animations: nextElement.animations, propertyPath: item.propertyPath, @@ -79,7 +89,7 @@ export class PasteKeyframesCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: KeyframeClipboardItem[]; constructor({ @@ -90,7 +100,7 @@ export class PasteKeyframesCommand extends Command { }: { trackId: string; elementId: string; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }) { super(); diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts index 4523566e..be0c2b91 100644 --- a/apps/web/src/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/commands/timeline/clipboard/paste.ts @@ -13,18 +13,25 @@ import { enforceMainTrackStart, } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class PasteCommand extends Command { private savedState: SceneTracks | null = null; private pastedElements: { trackId: string; elementId: string }[] = []; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: ElementClipboardItem[]; constructor({ time, clipboardItems, }: { - time: number; + time: MediaTime; clipboardItems: ElementClipboardItem[]; }) { super(); @@ -39,8 +46,12 @@ export class PasteCommand extends Command { this.savedState = editor.scenes.getActiveScene().tracks; this.pastedElements = []; - const minStart = Math.min( - ...this.clipboardItems.map((item) => item.element.startTime), + const minStart = this.clipboardItems.reduce( + (earliestStartTime, item) => + item.element.startTime < earliestStartTime + ? item.element.startTime + : earliestStartTime, + this.clipboardItems[0].element.startTime, ); let updatedTracks = this.savedState; @@ -97,12 +108,18 @@ export class PasteCommand extends Command { targetTrackId: targetTrack.id, requestedStartTime: earliestElement.startTime, }); - const delta = adjustedEarliestStartTime - earliestElement.startTime; + const delta = subMediaTime({ + a: adjustedEarliestStartTime, + b: earliestElement.startTime, + }); - if (delta !== 0) { + if (delta !== ZERO_MEDIA_TIME) { elementsForPlacement = elementsToAdd.map((element) => ({ ...element, - startTime: Math.max(0, element.startTime + delta), + startTime: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: element.startTime, b: delta }), + }), })); } } @@ -168,14 +185,20 @@ function buildPastedElements({ time, }: { items: ElementClipboardItem[]; - minStart: number; - time: number; + minStart: MediaTime; + time: MediaTime; }): TimelineElement[] { const elementsToAdd: TimelineElement[] = []; for (const item of items) { - const relativeOffset = item.element.startTime - minStart; - const startTime = Math.max(0, time + relativeOffset); + const relativeOffset = subMediaTime({ + a: item.element.startTime, + b: minStart, + }); + const startTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: time, b: relativeOffset }), + }); const newElementId = generateUUID(); elementsToAdd.push({ diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts index 8f65f7c7..2b1ab4ea 100644 --- a/apps/web/src/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts @@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import type { MediaTime } from "@/wasm"; interface DuplicateElementsParams { elements: { trackId: string; elementId: string }[]; @@ -119,7 +120,7 @@ function buildDuplicateElement({ }: { element: TimelineElement; id: string; - startTime: number; + startTime: MediaTime; }): TimelineElement { return { ...element, diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts index 49ac6f2d..2110d67b 100644 --- a/apps/web/src/commands/timeline/element/insert-element.ts +++ b/apps/web/src/commands/timeline/element/insert-element.ts @@ -18,6 +18,7 @@ import { resolveTrackPlacement, validateElementTrackCompatibility, } from "@/timeline/placement"; +import { roundMediaTime } from "@/wasm"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -266,7 +267,12 @@ export class InsertElementCommand extends Command { placementResult.kind === "existingTrack" ? { ...element, - startTime: placementResult.adjustedStartTime ?? element.startTime, + startTime: + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ + time: placementResult.adjustedStartTime, + }) + : element.startTime, } : element; diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts index ccbdbc5d..9337e02d 100644 --- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class RetimeKeyframeCommand extends Command { private savedState: SceneTracks | null = null; @@ -11,7 +17,7 @@ export class RetimeKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; - private readonly nextTime: number; + private readonly nextTime: MediaTime; constructor({ trackId, @@ -24,7 +30,7 @@ export class RetimeKeyframeCommand extends Command { elementId: string; propertyPath: AnimationPath; keyframeId: string; - nextTime: number; + nextTime: MediaTime; }) { super(); this.trackId = trackId; @@ -47,7 +53,10 @@ export class RetimeKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.nextTime, b: element.duration }), + }); return { ...element, animations: retimeElementKeyframe({ diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index eae8f039..91ddb23d 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -9,6 +9,12 @@ import { updateElementInSceneTracks } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; import type { AnimationInterpolation } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertEffectParamKeyframeCommand extends Command { private savedState: SceneTracks | null = null; @@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { private readonly elementId: string; private readonly effectId: string; private readonly paramKey: string; - private readonly time: number; + private readonly time: MediaTime; private readonly value: number | string | boolean; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number | string | boolean; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: this.elementId, elementPredicate: isVisualElement, update: (element) => { - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); const propertyPath = buildEffectParamPath({ effectId: this.effectId, paramKey: this.paramKey, diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts index 30514146..66f0c434 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -8,13 +8,19 @@ import type { AnimationInterpolation, AnimationValue, } from "@/animation/types"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertKeyframeCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; - private readonly time: number; + private readonly time: MediaTime; private readonly value: AnimationValue; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -31,7 +37,7 @@ export class UpsertKeyframeCommand extends Command { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -63,7 +69,10 @@ export class UpsertKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); return { ...element, animations: upsertPathKeyframe({ diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts index 11c317f4..0a551fc3 100644 --- a/apps/web/src/commands/timeline/element/split-elements.ts +++ b/apps/web/src/commands/timeline/element/split-elements.ts @@ -9,12 +9,18 @@ import { EditorCore } from "@/core"; import { isRetimableElement } from "@/timeline"; import { splitAnimationsAtTime } from "@/animation"; import { getSourceSpanAtClipTime } from "@/retime"; +import { + addMediaTime, + type MediaTime, + roundMediaTime, + subMediaTime, +} from "@/wasm"; export class SplitElementsCommand extends Command { private savedState: SceneTracks | null = null; private rightSideElements: { trackId: string; elementId: string }[] = []; private readonly elements: { trackId: string; elementId: string }[]; - private readonly splitTime: number; + private readonly splitTime: MediaTime; private readonly retainSide: "both" | "left" | "right"; constructor({ @@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }) { super(); @@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command { return [element]; } - const relativeTime = this.splitTime - element.startTime; + const relativeTime = subMediaTime({ + a: this.splitTime, + b: element.startTime, + }); const leftVisibleDuration = relativeTime; - const rightVisibleDuration = element.duration - relativeTime; + const rightVisibleDuration = subMediaTime({ + a: element.duration, + b: relativeTime, + }); const retimeRef = isRetimableElement(element) ? element.retime : undefined; - const leftSourceSpan = getSourceSpanAtClipTime({ - clipTime: leftVisibleDuration, - retime: retimeRef, + // Snap the source-side split point exactly once and derive the right + // half from it. Independently rounding both spans (left and total) + // would let a 1-tick rounding error desynchronise them, breaking the + // invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`. + // See the same discipline in `compute-resize.ts` (snap-once comment). + const leftSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: leftVisibleDuration, + retime: retimeRef, + }), }); - const totalSourceSpan = getSourceSpanAtClipTime({ - clipTime: element.duration, - retime: retimeRef, + const totalSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: element.duration, + retime: retimeRef, + }), + }); + const rightSourceSpan = subMediaTime({ + a: totalSourceSpan, + b: leftSourceSpan, }); - const rightSourceSpan = totalSourceSpan - leftSourceSpan; const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ animations: element.animations, splitTime: relativeTime, @@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command { }); let splitResult: TimelineElement[]; + const leftTrimEnd = addMediaTime({ + a: element.trimEnd, + b: rightSourceSpan, + }); + const rightTrimStart = addMediaTime({ + a: element.trimStart, + b: leftSourceSpan, + }); + if (this.retainSide === "left") { splitResult = [ { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command { id: newId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, ]; } else { - // "both" - split into two pieces const secondElementId = generateUUID(); this.rightSideElements.push({ trackId: track.id, @@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command { { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command { id: secondElementId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 276cb490..8450cec2 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -3,13 +3,14 @@ import { useEffect, useRef, useState } from "react"; import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; interface EditableTimecodeProps { - time: number; - duration: number; + time: MediaTime; + duration: MediaTime; format?: TimeCodeFormat; fps: FrameRate; - onTimeChange?: ({ time }: { time: number }) => void; + onTimeChange?: ({ time }: { time: MediaTime }) => void; className?: string; disabled?: boolean; } @@ -53,9 +54,12 @@ export function EditableTimecode({ return; } - const clampedTime = duration - ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime) - : parsedTime; + const clampedTime = ( + duration + ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? + parsedTime) + : parsedTime + ) as MediaTime; onTimeChange?.({ time: clampedTime }); setIsEditing(false); diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 2f8bc64a..fdbf5e76 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -14,13 +14,14 @@ import { useEditor } from "@/editor/use-editor"; import { clearDragData, setDragData } from "@/timeline/drag-data"; import type { TimelineDragData } from "@/timeline/drag"; import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; export interface DraggableItemProps { name: string; preview: ReactNode; dragData: TimelineDragData; onDragStart?: ({ e }: { e: React.DragEvent }) => void; - onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; + onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void; aspectRatio?: number; className?: string; containerClassName?: string; diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 4afd22b7..c071fdc7 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -26,7 +26,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { useFileUpload } from "@/media/use-file-upload"; import { invokeAction } from "@/actions"; @@ -255,11 +255,11 @@ function MediaAssetDraggable({ startTime, }: { asset: MediaAsset; - startTime: number; + startTime: MediaTime; }) => { const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: asset.id, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 967156b3..83e31aec 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,22 +1,25 @@ import { useEditor } from "@/editor/use-editor"; import { getElementLocalTime } from "@/animation"; +import { addMediaTime, mediaTime, type MediaTime } from "@/wasm"; export function useElementPlayhead({ startTime, duration, }: { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; }) { const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, + const localTime = mediaTime({ + ticks: getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: startTime, + elementDuration: duration, + }), }); const isPlayheadWithinElementRange = playheadTime >= startTime && - playheadTime <= startTime + duration; + playheadTime <= addMediaTime({ a: startTime, b: duration }); return { localTime, isPlayheadWithinElementRange }; } diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index 734b514f..f6216208 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -6,6 +6,7 @@ import { } from "@/animation"; import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export function useKeyframedColorProperty({ trackId, @@ -21,7 +22,7 @@ export function useKeyframedColorProperty({ elementId: string; animations: ElementAnimations | undefined; propertyPath: AnimationPropertyPath; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedColor: string; buildBaseUpdates: ({ value }: { value: string }) => Partial; diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 9dd39768..d52f7d97 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -8,6 +8,7 @@ import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types import type { TimelineElement } from "@/timeline"; import { snapToStep } from "@/utils/math"; import { usePropertyDraft } from "./use-property-draft"; +import type { MediaTime } from "@/wasm"; export function useKeyframedNumberProperty({ trackId, @@ -27,7 +28,7 @@ export function useKeyframedNumberProperty({ elementId: string; animations: ElementAnimations | undefined; propertyPath: AnimationPropertyPath; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; displayValue: string; parse: (input: string) => number | null; diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index b0b6f01a..e679f5c3 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -15,6 +15,7 @@ import type { } from "@/animation/types"; import type { ParamDefinition } from "@/params"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface KeyframedParamPropertyResult { hasAnimatedKeyframes: boolean; @@ -39,7 +40,7 @@ export function useKeyframedParamProperty({ trackId: string; elementId: string; animations: ElementAnimations | undefined; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedValue: number | string | boolean; buildBaseUpdates: ({ diff --git a/apps/web/src/core/managers/clipboard-manager.ts b/apps/web/src/core/managers/clipboard-manager.ts index 8a2a51c9..283a78ae 100644 --- a/apps/web/src/core/managers/clipboard-manager.ts +++ b/apps/web/src/core/managers/clipboard-manager.ts @@ -6,6 +6,7 @@ import { type CopyContext, type PasteContext, } from "@/clipboard"; +import type { MediaTime } from "@/wasm"; export class ClipboardManager { private entry: ClipboardEntry | null = null; @@ -34,7 +35,7 @@ export class ClipboardManager { return true; } - paste({ time }: { time?: number } = {}): boolean { + paste({ time }: { time?: MediaTime } = {}): boolean { if (!this.entry) { return false; } @@ -64,7 +65,7 @@ export class ClipboardManager { }; } - private getPasteContext({ time }: { time?: number }): PasteContext { + private getPasteContext({ time }: { time?: MediaTime }): PasteContext { return { editor: this.editor, selectedElements: this.editor.selection.getSelectedElements(), diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 1ea09826..6748fd42 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -1,10 +1,16 @@ import type { EditorCore } from "@/core"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + clampMediaTime, + type MediaTime, + mediaTimeFromSeconds, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { roundToFrame } from "opencut-wasm"; export class PlaybackManager { private isPlaying = false; - private currentTime = 0; + private currentTime: MediaTime = ZERO_MEDIA_TIME; private volume = 1; private muted = false; private previousVolume = 1; @@ -12,7 +18,7 @@ export class PlaybackManager { private listeners = new Set<() => void>(); private playbackTimer: number | null = null; private playbackStartWallTime = 0; - private playbackStartTime = 0; + private playbackStartTime: MediaTime = ZERO_MEDIA_TIME; private timelineScopeBound = false; constructor(private editor: EditorCore) {} @@ -38,7 +44,7 @@ export class PlaybackManager { } if (this.currentTime >= maxTime) { - this.seek({ time: 0 }); + this.seek({ time: ZERO_MEDIA_TIME }); } this.isPlaying = true; @@ -60,7 +66,7 @@ export class PlaybackManager { } } - seek({ time }: { time: number }): void { + seek({ time }: { time: MediaTime }): void { this.currentTime = this.clampTimeToTimeline(time); if (this.isPlaying) { this.playbackStartWallTime = performance.now(); @@ -107,7 +113,7 @@ export class PlaybackManager { return this.isPlaying; } - getCurrentTime(): number { + getCurrentTime(): MediaTime { return this.currentTime; } @@ -185,11 +191,13 @@ export class PlaybackManager { const fps = this.editor.project.getActive()?.settings.fps; const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000; - const rawTime = - this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); - const newTime = fps + const rawTime = addMediaTime({ + a: this.playbackStartTime, + b: mediaTimeFromSeconds({ seconds: elapsedSeconds }), + }); + const newTime = (fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) - : rawTime; + : rawTime) as MediaTime; const maxTime = this.editor.timeline.getTotalDuration(); if (newTime >= maxTime) { @@ -205,12 +213,12 @@ export class PlaybackManager { this.playbackTimer = requestAnimationFrame(this.updateTime); }; - private clampTimeToTimeline(time: number): number { + private clampTimeToTimeline(time: MediaTime): MediaTime { const maxTime = this.editor.timeline.getTotalDuration(); - return Math.max(0, Math.min(maxTime, time)); + return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime }); } - private dispatchSeekEvent(time: number): void { + private dispatchSeekEvent(time: MediaTime): void { if (typeof window === "undefined") { return; } @@ -222,7 +230,7 @@ export class PlaybackManager { ); } - private dispatchUpdateEvent(time: number): void { + private dispatchUpdateEvent(time: MediaTime): void { if (typeof window === "undefined") { return; } diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 13f251a1..c9558a6d 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -1,5 +1,5 @@ import type { EditorCore } from "@/core"; -import type { SceneTracks, TScene } from "@/timeline"; +import type { Bookmark, SceneTracks, TScene } from "@/timeline"; import { storageService } from "@/services/storage/service"; import { getMainScene, @@ -21,6 +21,7 @@ import { ToggleBookmarkCommand, UpdateBookmarkCommand, } from "@/commands/scene"; +import type { MediaTime } from "@/wasm"; export class ScenesManager { private active: TScene | null = null; @@ -106,12 +107,12 @@ export class ScenesManager { this.notify(); } - async toggleBookmark({ time }: { time: number }): Promise { + async toggleBookmark({ time }: { time: MediaTime }): Promise { const command = new ToggleBookmarkCommand(time); this.editor.command.execute({ command }); } - isBookmarked({ time }: { time: number }): boolean { + isBookmarked({ time }: { time: MediaTime }): boolean { const activeScene = this.getActiveScene(); const activeProject = this.editor.project.getActive(); @@ -125,7 +126,7 @@ export class ScenesManager { return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime }); } - async removeBookmark({ time }: { time: number }): Promise { + async removeBookmark({ time }: { time: MediaTime }): Promise { const command = new RemoveBookmarkCommand(time); this.editor.command.execute({ command }); } @@ -134,8 +135,8 @@ export class ScenesManager { time, updates, }: { - time: number; - updates: Partial<{ note: string; color: string; duration: number }>; + time: MediaTime; + updates: Partial>; }): Promise { const command = new UpdateBookmarkCommand(time, updates); this.editor.command.execute({ command }); @@ -145,14 +146,14 @@ export class ScenesManager { fromTime, toTime, }: { - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Promise { const command = new MoveBookmarkCommand(fromTime, toTime); this.editor.command.execute({ command }); } - getBookmarkAtTime({ time }: { time: number }) { + getBookmarkAtTime({ time }: { time: MediaTime }) { const activeScene = this.active; const activeProject = this.editor.project.getActive(); diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index dbc3332b..a1ead6ca 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -10,6 +10,7 @@ import type { } from "@/timeline"; import { calculateTotalDuration } from "@/timeline"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import { canElementBeHidden, canElementHaveAudio, @@ -95,10 +96,10 @@ export class TimelineManager { pushHistory = true, }: { elementId: string; - trimStart: number; - trimEnd: number; - startTime?: number; - duration?: number; + trimStart: MediaTime; + trimEnd: MediaTime; + startTime?: MediaTime; + duration?: MediaTime; pushHistory?: boolean; }): void { const trackId = this.findTrackIdForElement({ elementId }); @@ -188,7 +189,7 @@ export class TimelineManager { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }): { trackId: string; elementId: string }[] { const command = new SplitElementsCommand({ @@ -200,20 +201,20 @@ export class TimelineManager { return command.getRightSideElements(); } - getTotalDuration(): number { + getTotalDuration(): MediaTime { const activeScene = this.editor.scenes.getActiveSceneOrNull(); if (!activeScene) { - return 0; + return ZERO_MEDIA_TIME; } return calculateTotalDuration({ tracks: activeScene.tracks }); } - getLastFrameTime(): number { + getLastFrameTime(): MediaTime { const duration = this.getTotalDuration(); const fps = this.editor.project.getActive()?.settings.fps; if (!fps || duration <= 0) return duration; - return lastFrameTime({ duration, rate: fps }) ?? duration; + return (lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime; } getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { @@ -483,7 +484,7 @@ export class TimelineManager { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -600,7 +601,7 @@ export class TimelineManager { elementId: string; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): void { const command = new RetimeKeyframeCommand({ trackId, @@ -658,7 +659,7 @@ export class TimelineManager { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number; interpolation?: "linear" | "hold"; keyframeId?: string; diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx index 60599772..549ef4b6 100644 --- a/apps/web/src/graphics/components/graphic-tab.tsx +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -24,6 +24,7 @@ import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; registerDefaultGraphics(); @@ -197,10 +198,11 @@ function AnimatedGraphicParamField({ isPlayheadWithinElementRange, resolvedParams, }: { + key?: string; param: ParamDefinition; trackId: string; element: GraphicElement; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedParams: ParamValues; }) { diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts index 3055920e..387b2b9a 100644 --- a/apps/web/src/media/use-paste-media.ts +++ b/apps/web/src/media/use-paste-media.ts @@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media"; import { InsertElementCommand } from "@/commands/timeline"; import { BatchCommand } from "@/commands"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds } from "@/wasm"; import { isTypableDOMElement } from "@/utils/browser"; import type { MediaType } from "@/media/types"; @@ -75,7 +75,7 @@ export function usePasteMedia() { const assetId = addMediaCmd.getAssetId(); const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const trackType = asset.type === "audio" ? "audio" : "video"; diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index b44e42ef..084c5b55 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -26,6 +26,7 @@ import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom"; import { usePreviewViewport } from "./preview-viewport"; import { GridPopover } from "./guide-popover"; import { usePreviewStore } from "@/preview/preview-store"; +import type { MediaTime } from "@/wasm"; export function PreviewToolbar({ onToggleFullscreen, @@ -67,13 +68,13 @@ function TimecodeDisplay() { const editor = useEditor(); const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); const fps = useEditor((e) => e.project.getActive().settings.fps); - const [currentTime, setCurrentTime] = useState(() => + const [currentTime, setCurrentTime] = useState(() => editor.playback.getCurrentTime(), ); useEffect(() => { const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); + setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time); window.addEventListener("playback-update", handler); window.addEventListener("playback-seek", handler); return () => { diff --git a/apps/web/src/project/types.ts b/apps/web/src/project/types.ts index 42e844f6..dca2d785 100644 --- a/apps/web/src/project/types.ts +++ b/apps/web/src/project/types.ts @@ -1,5 +1,6 @@ import type { FrameRate } from "opencut-wasm"; import type { TScene } from "@/timeline/types"; +import type { MediaTime } from "@/wasm"; export type TBackground = | { @@ -20,7 +21,7 @@ export interface TProjectMetadata { id: string; name: string; thumbnail?: string; - duration: number; + duration: MediaTime; createdAt: Date; updatedAt: Date; } @@ -37,7 +38,7 @@ export interface TProjectSettings { export interface TTimelineViewState { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; } export interface TProject { diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx index 3e0db46c..870390bf 100644 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ b/apps/web/src/rendering/components/blending-tab.tsx @@ -30,14 +30,15 @@ import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/ import { resolveOpacityAtTime } from "@/animation"; import { DEFAULTS } from "@/timeline/defaults"; import { isPropertyAtDefault } from "./transform-tab"; +import type { MediaTime } from "@/wasm"; type BlendingElement = { id: string; opacity: number; type: ElementType; blendMode?: BlendMode; - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; animations?: ElementAnimations; }; diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 5495ba3b..0623b399 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -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; 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 00000000..d467c8a1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts @@ -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; + 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>; + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([ + { + time: 120_000, + duration: 60_001, + note: "Marker", + color: "#ff0000", + }, + ]); + + const tracks = scene.tracks as Record; + const mainTrack = tracks.main as Record; + const elements = mainTrack.elements as Array>; + 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; + const channels = animations.channels as Record>; + 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; + 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>; + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]); + + const tracks = scene.tracks as Record; + const mainTrack = tracks.main as Record; + const element = (mainTrack.elements as Array>)[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"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index b290b713..150da7c4 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 00000000..fc8d8432 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts @@ -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 { + 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 }); +} 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 00000000..aaec6256 --- /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 858198eb..a388e807 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -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; 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), }); diff --git a/apps/web/src/subtitles/build-subtitle-text-element.ts b/apps/web/src/subtitles/build-subtitle-text-element.ts index c4ac21ac..4e0bdb85 100644 --- a/apps/web/src/subtitles/build-subtitle-text-element.ts +++ b/apps/web/src/subtitles/build-subtitle-text-element.ts @@ -5,7 +5,7 @@ import { setCanvasLetterSpacing, } from "@/text/layout"; import { DEFAULTS } from "@/timeline/defaults"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds } from "@/wasm"; import type { CreateTextElement } from "@/timeline"; import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; @@ -310,8 +310,8 @@ export function buildSubtitleTextElement({ ...DEFAULTS.text.element, name: `Caption ${index + 1}`, content, - duration: Math.round(caption.duration * TICKS_PER_SECOND), - startTime: Math.round(caption.startTime * TICKS_PER_SECOND), + duration: mediaTimeFromSeconds({ seconds: caption.duration }), + startTime: mediaTimeFromSeconds({ seconds: caption.startTime }), fontSize: style.fontSize, fontFamily: style.fontFamily, color: style.color, diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx index 25a49893..ffabe3b8 100644 --- a/apps/web/src/text/components/assets-view.tsx +++ b/apps/web/src/text/components/assets-view.tsx @@ -3,11 +3,12 @@ import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { useEditor } from "@/editor/use-editor"; import { DEFAULTS } from "@/timeline/defaults"; import { buildTextElement } from "@/timeline/element-utils"; +import type { MediaTime } from "@/wasm"; export function TextView() { const editor = useEditor(); - const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { + const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => { const activeScene = editor.scenes.getActiveScene(); if (!activeScene) return; diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts new file mode 100644 index 00000000..94b32ced --- /dev/null +++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import type { Transform } from "@/rendering"; +import type { SceneTracks, VideoElement } from "@/timeline"; +import { applyElementUpdate } from "@/timeline/update-pipeline"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +function buildVideoElement(overrides: Partial = {}): VideoElement { + return { + id: "video-1", + type: "video", + name: "Video 1", + startTime: ZERO_MEDIA_TIME, + duration: mediaTime({ ticks: 10 }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + mediaId: "media-1", + transform: buildTransform(), + opacity: 1, + ...overrides, + }; +} + +function buildTracks(element: VideoElement): SceneTracks { + return { + overlay: [], + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [element], + }, + audio: [], + }; +} + +describe("applyElementUpdate", () => { + test("rounds retimed durations back to integer media time", () => { + const element = buildVideoElement(); + const tracks = buildTracks(element); + + const updatedElement = applyElementUpdate({ + element, + patch: { + retime: { rate: 1.5 }, + }, + context: { + tracks, + trackId: tracks.main.id, + }, + }); + + expect(updatedElement.duration).toBe(7); + expect(Number.isInteger(updatedElement.duration)).toBe(true); + }); +}); diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx index 20e22c57..0f0710cb 100644 --- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -26,6 +26,13 @@ import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp, formatNumberForDisplay } from "@/utils/math"; import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline"; +import { + type MediaTime, + mediaTimeFromSeconds, + mediaTimeToSeconds, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; @@ -39,12 +46,12 @@ function seekToBookmarkTime({ time, }: { editor: EditorCore; - time: number; + time: MediaTime; }) { const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const rate = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; + const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime; editor.playback.seek({ time: snappedTime }); } @@ -137,7 +144,7 @@ function TimelineBookmark({ const displayTime = isDragging ? dragState.currentTime : bookmark.time; const time = bookmark.time; - const bookmarkDuration = bookmark.duration ?? 0; + const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME; const durationWidth = bookmarkDuration > 0 ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) @@ -182,7 +189,7 @@ function TimelineBookmark({ left: `${bookmarkLeft}px`, width: `${bookmarkWidth}px`, }} - aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} + aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`} type="button" onMouseDown={handleMouseDown} onClick={handleClick} @@ -286,8 +293,8 @@ function BookmarkPopoverContent({ onPopoverClose, }: { bookmark: Bookmark; - time: number; - timelineDuration: number; + time: MediaTime; + timelineDuration: MediaTime; onPopoverClose: () => void; }) { const editor = useEditor(); @@ -314,9 +321,8 @@ function BookmarkPopoverContent({ note, color, duration, - }: Partial<{ note: string; color: string; duration: number }>) => { - const updates: Partial<{ note: string; color: string; duration: number }> = - {}; + }: Partial>) => { + const updates: Partial> = {}; if (note !== undefined && note !== bookmark.note) updates.note = note; if ( color !== undefined && @@ -330,6 +336,15 @@ function BookmarkPopoverContent({ if (Object.keys(updates).length === 0) return; editor.scenes.updateBookmark({ time, updates }); }; + const maxDuration = mediaTimeToSeconds({ + time: + timelineDuration > time + ? subMediaTime({ a: timelineDuration, b: time }) + : ZERO_MEDIA_TIME, + }); + const durationSeconds = mediaTimeToSeconds({ + time: bookmark.duration ?? ZERO_MEDIA_TIME, + }); return ( <> @@ -387,7 +402,7 @@ function BookmarkPopoverContent({ type="number" min={0} step={0.1} - value={bookmark.duration ?? 0} + value={durationSeconds} onChange={(event) => { const parsed = parseFloat(event.target.value); const value = Number.isNaN(parsed) @@ -395,9 +410,11 @@ function BookmarkPopoverContent({ : clamp({ value: parsed, min: 0, - max: Math.max(0, timelineDuration - time), + max: maxDuration, }); - handleUpdate({ duration: value }); + handleUpdate({ + duration: mediaTimeFromSeconds({ seconds: value }), + }); }} className="h-8 text-sm" containerClassName="w-full" diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 1071c6dd..8996330b 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -21,15 +21,16 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; import type { Bookmark } from "@/timeline"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export interface BookmarkDragState { isDragging: boolean; - bookmarkTime: number | null; - currentTime: number; + bookmarkTime: MediaTime | null; + currentTime: MediaTime; } interface PendingBookmarkDrag { - bookmarkTime: number; + bookmarkTime: MediaTime; startMouseX: number; startMouseY: number; } @@ -58,7 +59,7 @@ export function useBookmarkDrag({ const [dragState, setDragState] = useState({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef(null); @@ -69,8 +70,8 @@ export function useBookmarkDrag({ bookmarkTime, initialCurrentTime, }: { - bookmarkTime: number; - initialCurrentTime: number; + bookmarkTime: MediaTime; + initialCurrentTime: MediaTime; }) => { setDragState({ isDragging: true, @@ -85,7 +86,7 @@ export function useBookmarkDrag({ setDragState({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); }, []); @@ -94,9 +95,9 @@ export function useBookmarkDrag({ rawTime, excludeBookmarkTime, }: { - rawTime: number; - excludeBookmarkTime: number; - }): { snappedTime: number; snapPoint: SnapPoint | null } => { + rawTime: MediaTime; + excludeBookmarkTime: MediaTime; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => { const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (!shouldSnap) { return { snappedTime: rawTime, snapPoint: null }; @@ -116,7 +117,7 @@ export function useBookmarkDrag({ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), }); return { - snappedTime: result.snappedTime, + snappedTime: result.snappedTime as MediaTime, snapPoint: result.snapPoint, }; }, @@ -162,11 +163,14 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const frameSnappedTime = + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = ( roundToFrame({ - time: Math.max(0, Math.min(mouseTime, duration)), + time: clampedTime, rate: activeProject.settings.fps, - }) ?? Math.max(0, Math.min(mouseTime, duration)); + }) ?? clampedTime + ) as MediaTime; const { snappedTime: initialTime } = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: bookmarkTime, @@ -193,10 +197,12 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const clampedTime = Math.max(0, Math.min(mouseTime, duration)); - const frameSnappedTime = + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = ( roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? - clampedTime; + clampedTime + ) as MediaTime; const snapResult = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: dragState.bookmarkTime, @@ -234,10 +240,8 @@ export function useBookmarkDrag({ return; } - const clampedTime = Math.max( - 0, - Math.min(dragState.currentTime, duration), - ); + const clampedTime = + dragState.currentTime > duration ? duration : dragState.currentTime; editor.scenes.moveBookmark({ fromTime: dragState.bookmarkTime, diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx index 8e2b4bcf..2c254c84 100644 --- a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx +++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx @@ -5,6 +5,7 @@ import { type PreviewOverlaySourceResult, } from "@/preview/overlays"; import { getBookmarksActiveAtTime } from "./utils"; +import type { MediaTime } from "@/wasm"; export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { id: "bookmark-notes", @@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { function BookmarkNotesOverlay({ bookmarks, }: { - bookmarks: Array<{ time: number; note: string; color?: string }>; + bookmarks: Array<{ time: MediaTime; note: string; color?: string }>; }) { return (
@@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({ isVisible, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; isVisible: boolean; }): PreviewOverlaySourceResult { const bookmarksWithNotes = getBookmarksActiveAtTime({ diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts index d05fe3a9..52e5aa60 100644 --- a/apps/web/src/timeline/bookmarks/utils.ts +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -1,13 +1,14 @@ import type { Bookmark } from "@/timeline"; import type { FrameRate } from "opencut-wasm"; import { roundToFrame } from "opencut-wasm"; +import { addMediaTime, type MediaTime } from "@/wasm"; function bookmarkTimeEqual({ bookmarkTime, frameTime, }: { - bookmarkTime: number; - frameTime: number; + bookmarkTime: MediaTime; + frameTime: MediaTime; }): boolean { return bookmarkTime === frameTime; } @@ -17,7 +18,7 @@ export function findBookmarkIndex({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): number { return bookmarks.findIndex((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -29,7 +30,7 @@ export function isBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): boolean { return bookmarks.some((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -41,7 +42,7 @@ export function toggleBookmarkInArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); @@ -58,7 +59,7 @@ export function removeBookmarkFromArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { return bookmarks.filter( (bookmark) => @@ -72,7 +73,7 @@ export function updateBookmarkInArray({ updates, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; updates: Partial>; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime }); @@ -92,8 +93,8 @@ export function moveBookmarkInArray({ toTime, }: { bookmarks: Bookmark[]; - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); if (index === -1) { @@ -110,10 +111,10 @@ export function getFrameTime({ time, fps, }: { - time: number; + time: MediaTime; fps: FrameRate; -}): number { - return roundToFrame({ time, rate: fps }) ?? time; +}): MediaTime { + return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime; } export function getBookmarkAtTime({ @@ -121,7 +122,7 @@ export function getBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark | null { const index = findBookmarkIndex({ bookmarks, frameTime }); return index === -1 ? null : bookmarks[index]; @@ -132,13 +133,13 @@ export function getBookmarksActiveAtTime({ time, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; }): Bookmark[] { return bookmarks.filter((bookmark) => { const start = bookmark.time; const end = bookmark.duration != null && bookmark.duration > 0 - ? start + bookmark.duration + ? addMediaTime({ a: start, b: bookmark.duration }) : start; return time >= start && time <= end; }); diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts index be2495e4..f68a4b72 100644 --- a/apps/web/src/timeline/components/drop-target.ts +++ b/apps/web/src/timeline/components/drop-target.ts @@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline"; import { resolveTrackPlacement } from "@/timeline/placement"; import { TIMELINE_TRACK_GAP_PX } from "./layout"; import { getTrackHeight } from "./track-layout"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + mediaTime, + type MediaTime, + roundMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; function findElementAtPosition({ mouseX, @@ -20,9 +25,11 @@ function findElementAtPosition({ pixelsPerSecond: number; zoomLevel: number; }): { elementId: string; trackId: string } | null { - const time = Math.round( - (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + const time = mediaTime({ + ticks: Math.round( + (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, + ), + }); const track = tracks[trackIndex]; if (!track || !("elements" in track)) return null; @@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null; function fallbackNewTrackDropTarget({ xPosition, }: { - xPosition: number; + xPosition: MediaTime; }): DropTarget { return { trackIndex: 0, @@ -111,13 +118,16 @@ export function computeDropTarget({ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const mainTrackIndex = tracks.overlay.length; const xPosition = - typeof startTimeOverride === "number" + startTimeOverride !== undefined ? startTimeOverride : isExternalDrop ? playheadTime - : Math.round( - Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + : mediaTime({ + ticks: Math.round( + Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * + TICKS_PER_SECOND, + ), + }); if (orderedTracks.length === 0) { const placementResult = resolveTrackPlacement({ @@ -221,7 +231,10 @@ export function computeDropTarget({ } if (placementResult.kind === "existingTrack") { - const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; + const adjustedXPosition = + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ time: placementResult.adjustedStartTime }) + : xPosition; return { trackIndex: placementResult.trackIndex, diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index e6a9df49..5d61a0a9 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,6 +29,7 @@ import { useState, type ReactNode, } from "react"; +import type { MediaTime } from "@/wasm"; import type { ElementDragState, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; @@ -132,7 +133,7 @@ export function Timeline() { [scene], ); const mainTrackId = scene?.tracks.main.id ?? null; - const seek = (time: number) => editor.playback.seek({ time }); + const seek = (time: MediaTime) => editor.playback.seek({ time }); const timelineRef = useRef(null); const timelineHeaderRef = useRef(null); diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index 74c34607..e0eeb378 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -49,7 +49,7 @@ import { import { buildWaveformGainSamples } from "@/timeline/audio-state"; import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { addMediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm"; import { getActionDefinition, type TAction, @@ -257,10 +257,11 @@ export function TimelineElement({ isBeingDragged && dragState.isDragging ? dragState.currentMouseY - dragState.startMouseY : 0; - const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; + const dragTimeOffset = + dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME; const elementStartTime = isBeingDragged && dragState.isDragging - ? dragState.currentTime + dragTimeOffset + ? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset }) : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx index 4dd3329f..5b1e45ab 100644 --- a/apps/web/src/timeline/components/timeline-playhead.tsx +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -7,7 +7,14 @@ import { timelineTimeToSnappedPixels, } from "@/timeline"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout"; import { TIMELINE_LAYERS } from "./layers"; @@ -72,17 +79,24 @@ export function TimelinePlayhead({ event.preventDefault(); const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); const direction = event.key === "ArrowRight" ? 1 : -1; const now = editor.playback.getCurrentTime(); - const nextTime = Math.max( - 0, - Math.min(duration, now + direction * ticksPerFrame), - ); + const nextTime = + direction > 0 + ? addMediaTime({ a: now, b: ticksPerFrame }) + : subMediaTime({ a: now, b: ticksPerFrame }); - editor.playback.seek({ time: nextTime }); + editor.playback.seek({ + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: duration < nextTime ? duration : nextTime, + }), + }); }; return ( diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index 2ac7f246..f3c93b8d 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,3 +1,5 @@ -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { mediaTime, TICKS_PER_SECOND } from "@/wasm"; -export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; +export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({ + ticks: 5 * TICKS_PER_SECOND, +}); diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts index 06639e01..3b47b4f4 100644 --- a/apps/web/src/timeline/defaults.ts +++ b/apps/web/src/timeline/defaults.ts @@ -1,6 +1,7 @@ import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import type { TTimelineViewState } from "@/project/types"; import type { BlendMode, Transform } from "@/rendering"; +import { ZERO_MEDIA_TIME } from "@/wasm"; import type { TextElement } from "./types"; const defaultTransform: Transform = { @@ -42,9 +43,9 @@ const defaultTextElement: Omit = { letterSpacing: defaultTextLetterSpacing, lineHeight: defaultTextLineHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, + startTime: ZERO_MEDIA_TIME, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...defaultTransform, position: { ...defaultTransform.position }, @@ -55,7 +56,7 @@ const defaultTextElement: Omit = { const defaultTimelineViewState: TTimelineViewState = { zoomLevel: 1, scrollLeft: 0, - playheadTime: 0, + playheadTime: ZERO_MEDIA_TIME, }; export const DEFAULTS = { diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts index a731d7a0..ad8d8001 100644 --- a/apps/web/src/timeline/drag-utils.ts +++ b/apps/web/src/timeline/drag-utils.ts @@ -1,5 +1,5 @@ import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; export function getMouseTimeFromClientX({ clientX, @@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({ containerRect: DOMRect; zoomLevel: number; scrollLeft: number; -}): number { +}): MediaTime { const mouseX = clientX - containerRect.left + scrollLeft; const seconds = Math.max( 0, mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ + ticks: Math.round(seconds * TICKS_PER_SECOND), + }); } diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts index 676bc823..cddbf6fd 100644 --- a/apps/web/src/timeline/element-utils.ts +++ b/apps/web/src/timeline/element-utils.ts @@ -29,6 +29,7 @@ import { buildDefaultEffectInstance } from "@/effects"; import { buildDefaultGraphicInstance } from "@/graphics"; import type { ParamValues } from "@/params"; import { capitalizeFirstLetter } from "@/utils/string"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export function canElementHaveAudio( element: TimelineElement, @@ -107,7 +108,7 @@ export function buildTextElement({ startTime, }: { raw: Partial>; - startTime: number; + startTime: MediaTime; }): CreateTimelineElement { const t = raw as Partial; @@ -117,8 +118,8 @@ export function buildTextElement({ content: t.content ?? DEFAULTS.text.element.content, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, color: t.color ?? DEFAULTS.text.element.color, @@ -141,8 +142,8 @@ export function buildEffectElement({ duration, }: { effectType: string; - startTime: number; - duration?: number; + startTime: MediaTime; + duration?: MediaTime; }): CreateEffectElement { const instance = buildDefaultEffectInstance({ effectType }); return { @@ -152,8 +153,8 @@ export function buildEffectElement({ params: instance.params, duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, }; } @@ -166,7 +167,7 @@ export function buildStickerElement({ }: { stickerId: string; name?: string; - startTime: number; + startTime: MediaTime; intrinsicWidth?: number; intrinsicHeight?: number; }): CreateStickerElement { @@ -180,8 +181,8 @@ export function buildStickerElement({ intrinsicHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...DEFAULTS.element.transform, position: { ...DEFAULTS.element.transform.position }, @@ -199,7 +200,7 @@ export function buildGraphicElement({ }: { definitionId: string; name?: string; - startTime: number; + startTime: MediaTime; params?: Partial; }): CreateGraphicElement { const instance = buildDefaultGraphicInstance({ definitionId }); @@ -210,8 +211,8 @@ export function buildGraphicElement({ params: { ...instance.params, ...(params ?? {}) } as ParamValues, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...DEFAULTS.element.transform, position: { ...DEFAULTS.element.transform.position }, @@ -229,8 +230,8 @@ function buildVideoElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateVideoElement { return { type: "video", @@ -238,8 +239,8 @@ function buildVideoElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, muted: false, isSourceAudioEnabled: true, @@ -262,8 +263,8 @@ function buildImageElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateImageElement { return { type: "image", @@ -271,8 +272,8 @@ function buildImageElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, hidden: false, transform: { ...DEFAULTS.element.transform, @@ -292,8 +293,8 @@ function buildUploadAudioElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateUploadAudioElement { const element: CreateUploadAudioElement = { @@ -303,8 +304,8 @@ function buildUploadAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, volume: DEFAULTS.element.volume, muted: false, @@ -326,8 +327,8 @@ export function buildElementFromMedia({ mediaId: string; mediaType: MediaType; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateTimelineElement { switch (mediaType) { @@ -355,8 +356,8 @@ export function buildLibraryAudioElement({ }: { sourceUrl: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateLibraryAudioElement { const element: CreateLibraryAudioElement = { @@ -366,8 +367,8 @@ export function buildLibraryAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, volume: DEFAULTS.element.volume, muted: false, diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts index 00f39d55..3e19e3e3 100644 --- a/apps/web/src/timeline/group-move/build-group.ts +++ b/apps/web/src/timeline/group-move/build-group.ts @@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import type { GroupMember, MoveGroup } from "./types"; import { getTrackPlacementById } from "./track-placement"; +import { subMediaTime } from "@/wasm"; export function buildMoveGroup({ anchorRef, @@ -59,7 +60,10 @@ export function buildMoveGroup({ elementId: element.id, elementType: element.type, duration: element.duration, - timeOffset: element.startTime - anchorElement.startTime, + timeOffset: subMediaTime({ + a: element.startTime, + b: anchorElement.startTime, + }), trackSection: placement.section, sectionIndex: placement.sectionIndex, displayIndex: placement.displayIndex, diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts index 60940315..8307aad0 100644 --- a/apps/web/src/timeline/group-move/resolve-move.ts +++ b/apps/web/src/timeline/group-move/resolve-move.ts @@ -12,6 +12,13 @@ import { getTrackPlacementByDisplayIndex, getTrackPlacementById, } from "./track-placement"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; type GroupMoveTarget = | { @@ -32,7 +39,7 @@ export function resolveGroupMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; target: GroupMoveTarget; }): GroupMoveResult | null { if (target.kind === "newTracks") { @@ -61,7 +68,7 @@ function resolveExistingTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorTargetTrackId: string; }): GroupMoveResult | null { const anchorTargetPlacement = getTrackPlacementById({ @@ -93,7 +100,10 @@ function resolveExistingTrackMove({ targetTrackId: targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); if (!canApplyMovesToExistingTracks({ tracks, moves })) { @@ -119,7 +129,7 @@ function resolveNewTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorInsertIndex: number; newTrackIds: string[]; }): GroupMoveResult | null { @@ -173,7 +183,10 @@ function resolveNewTrackMove({ sourceTrackId: member.trackId, targetTrackId: newTrackIds[memberIndex], elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); return { @@ -335,17 +348,26 @@ function clampAnchorStartTime({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; targetTrackIdsByElementId: Map; -}): number { - const minimumAnchorStartTime = Math.max( - 0, - ...group.members.map((member) => -member.timeOffset), - ); - let clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - anchorStartTime, +}): MediaTime { + const minimumAnchorStartTime = group.members.reduce( + (minimumStartTime, member) => + member.timeOffset < ZERO_MEDIA_TIME + ? maxMediaTime({ + a: minimumStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: member.timeOffset, + }), + }) + : minimumStartTime, + ZERO_MEDIA_TIME, ); + let clampedAnchorStartTime = + anchorStartTime < minimumAnchorStartTime + ? minimumAnchorStartTime + : anchorStartTime; const memberOnMainTrack = group.members.find( (member) => @@ -358,11 +380,13 @@ function clampAnchorStartTime({ const movingElementIds = new Set( group.members.map((member) => member.elementId), ); - const requestedMainStartTime = - clampedAnchorStartTime + memberOnMainTrack.timeOffset; + const requestedMainStartTime = addMediaTime({ + a: clampedAnchorStartTime, + b: memberOnMainTrack.timeOffset, + }); const earliestStationaryMainStartTime = tracks.main.elements .filter((element) => !movingElementIds.has(element.id)) - .reduce((earliestStartTime, element) => { + .reduce((earliestStartTime, element) => { if (earliestStartTime == null || element.startTime < earliestStartTime) { return element.startTime; } @@ -373,10 +397,13 @@ function clampAnchorStartTime({ earliestStationaryMainStartTime == null || requestedMainStartTime <= earliestStationaryMainStartTime ) { - clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - -memberOnMainTrack.timeOffset, - ); + clampedAnchorStartTime = maxMediaTime({ + a: minimumAnchorStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: memberOnMainTrack.timeOffset, + }), + }); } return clampedAnchorStartTime; @@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({ const sourceElement = sourceElements.get(move.elementId); return { startTime: move.newStartTime, - duration: sourceElement?.duration ?? 0, + duration: sourceElement?.duration ?? ZERO_MEDIA_TIME, }; }); if (hasOverlappingTimeSpans({ timeSpans })) { diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index 32c0a2f1..aa06a35b 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -9,6 +9,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; import type { MoveGroup } from "./types"; +import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm"; export function snapGroupEdges({ group, @@ -18,12 +19,12 @@ export function snapGroupEdges({ zoomLevel, }: { group: MoveGroup; - anchorStartTime: number; + anchorStartTime: MediaTime; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; zoomLevel: number; }): { - snappedAnchorStartTime: number; + snappedAnchorStartTime: MediaTime; snapPoint: SnapPoint | null; } { const excludeElementIds = new Set( @@ -47,7 +48,10 @@ export function snapGroupEdges({ let snapPoint: SnapPoint | null = null; for (const member of group.members) { - const memberStartTime = anchorStartTime + member.timeOffset; + const memberStartTime = addMediaTime({ + a: anchorStartTime, + b: member.timeOffset, + }); const memberStartSnap = resolveTimelineSnap({ targetTime: memberStartTime, snapPoints, @@ -58,12 +62,18 @@ export function snapGroupEdges({ memberStartSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberStartSnap.snapDistance; - snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: memberStartSnap.snappedTime as MediaTime, + b: member.timeOffset, + }); snapPoint = memberStartSnap.snapPoint; } const memberEndSnap = resolveTimelineSnap({ - targetTime: memberStartTime + member.duration, + targetTime: addMediaTime({ + a: memberStartTime, + b: member.duration, + }), snapPoints, maxSnapDistance, }); @@ -72,8 +82,13 @@ export function snapGroupEdges({ memberEndSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberEndSnap.snapDistance; - snappedAnchorStartTime = - memberEndSnap.snappedTime - member.duration - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: subMediaTime({ + a: memberEndSnap.snappedTime as MediaTime, + b: member.duration, + }), + b: member.timeOffset, + }); snapPoint = memberEndSnap.snapPoint; } } diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts index daf9c6e9..f97b698c 100644 --- a/apps/web/src/timeline/group-move/types.ts +++ b/apps/web/src/timeline/group-move/types.ts @@ -1,11 +1,12 @@ import type { ElementRef, ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export type GroupTrackSection = "overlay" | "main" | "audio"; export interface GroupMember extends ElementRef { elementType: ElementType; - duration: number; - timeOffset: number; + duration: MediaTime; + timeOffset: MediaTime; trackSection: GroupTrackSection; sectionIndex: number; displayIndex: number; @@ -26,7 +27,7 @@ export interface PlannedElementMove { sourceTrackId: string; targetTrackId: string; elementId: string; - newStartTime: number; + newStartTime: MediaTime; } export interface GroupMoveResult { diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index dd542967..bbe829cc 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -3,7 +3,7 @@ import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/retime"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { TICKS_PER_SECOND, roundMediaTime } from "@/wasm"; import type { ComputeGroupResizeArgs, GroupResizeMember, @@ -188,15 +188,17 @@ function getSourceDeltaForClipDelta({ return clipDelta; } - return clipDelta >= 0 - ? getSourceSpanAtClipTime({ - clipTime: clipDelta, - retime: member.retime, - }) - : -getSourceSpanAtClipTime({ - clipTime: Math.abs(clipDelta), - retime: member.retime, - }); + const sourceDelta = + clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: member.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: member.retime, + }); + return roundMediaTime({ time: sourceDelta }); } function getVisibleSourceSpanForDuration({ @@ -227,9 +229,11 @@ function getDurationForVisibleSourceSpan({ return sourceSpan; } - return getTimelineDurationForSourceSpan({ - sourceSpan, - retime: member.retime, + return roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan, + retime: member.retime, + }), }); } diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 621259b9..9caca28a 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -17,7 +17,14 @@ import { type MoveGroup, } from "@/timeline/group-move"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + type MediaTime, + mediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; import { roundToFrame } from "opencut-wasm"; import { computeDropTarget } from "@/timeline/components/drop-target"; @@ -54,9 +61,9 @@ const initialDragState: ElementDragState = { trackId: null, startMouseX: 0, startMouseY: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, + startElementTime: ZERO_MEDIA_TIME, + clickOffsetTime: ZERO_MEDIA_TIME, + currentTime: ZERO_MEDIA_TIME, currentMouseY: 0, }; @@ -66,8 +73,8 @@ interface PendingDragState { selectedElements: ElementRef[]; startMouseX: number; startMouseY: number; - startElementTime: number; - clickOffsetTime: number; + startElementTime: MediaTime; + clickOffsetTime: MediaTime; } function getClickOffsetTime({ @@ -78,10 +85,12 @@ function getClickOffsetTime({ clientX: number; elementRect: DOMRect; zoomLevel: number; -}): number { +}): MediaTime { const clickOffsetX = clientX - elementRect.left; const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ + ticks: Math.round(seconds * TICKS_PER_SECOND), + }); } function getVerticalDragDirection({ @@ -118,7 +127,7 @@ function getDragDropTarget({ tracksScrollRef: RefObject; headerRef?: RefObject; zoomLevel: number; - snappedTime: number; + snappedTime: MediaTime; verticalDragDirection?: "up" | "down" | null; }): DropTarget | null { const containerRect = tracksContainerRef.current?.getBoundingClientRect(); @@ -162,7 +171,7 @@ interface StartDragParams ElementDragState, "isDragging" | "currentTime" | "currentMouseY" > { - initialCurrentTime: number; + initialCurrentTime: MediaTime; initialCurrentMouseY: number; } @@ -249,7 +258,7 @@ export function useElementInteraction({ dropTarget, }: { group: MoveGroup; - snappedTime: number; + snappedTime: MediaTime; dropTarget: DropTarget | null; }): GroupMoveResult | null => { if (!dropTarget) { @@ -317,7 +326,7 @@ export function useElementInteraction({ frameSnappedTime, group, }: { - frameSnappedTime: number; + frameSnappedTime: MediaTime; group: MoveGroup | null; }) => { if (!group || !snappingEnabled || isShiftHeldRef.current) { @@ -366,15 +375,19 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = + const adjustedTime = + mouseTime > pendingDragRef.current.clickOffsetTime + ? subMediaTime({ + a: mouseTime, + b: pendingDragRef.current.clickOffsetTime, + }) + : ZERO_MEDIA_TIME; + const snappedTime = ( roundToFrame({ time: adjustedTime, rate: activeProject.settings.fps, - }) ?? adjustedTime; + }) ?? adjustedTime + ) as MediaTime; const moveGroup = buildMoveGroup({ anchorRef: { trackId: pendingDragRef.current.trackId, @@ -389,7 +402,7 @@ export function useElementInteraction({ moveGroupRef.current = moveGroup; newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record = {}; + const dragTimeOffsets: Record = {}; for (const member of moveGroup.members) { dragTimeOffsets[member.elementId] = member.timeOffset; } @@ -478,10 +491,17 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const adjustedTime = + mouseTime > dragState.clickOffsetTime + ? subMediaTime({ + a: mouseTime, + b: dragState.clickOffsetTime, + }) + : ZERO_MEDIA_TIME; const fps = activeProject.settings.fps; - const frameSnappedTime = - roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; + const frameSnappedTime = ( + roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime + ) as MediaTime; const moveGroup = moveGroupRef.current; const { snappedTime, snapPoint } = getDragSnapResult({ @@ -596,8 +616,10 @@ export function useElementInteraction({ const currentMember = moveGroup.members.find( (member) => member.elementId === move.elementId, ); - const originalStartTime = - dragState.startElementTime + (currentMember?.timeOffset ?? 0); + const originalStartTime = addMediaTime({ + a: dragState.startElementTime, + b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME, + }); return ( currentMember?.trackId !== move.targetTrackId || originalStartTime !== move.newStartTime diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts index 92b6f0d8..afead4e9 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -11,7 +11,15 @@ import { useKeyframeSelection } from "./use-keyframe-selection"; import { roundToFrame, snappedSeekTime } from "opencut-wasm"; import { timelineTimeToSnappedPixels } from "@/timeline"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + mediaTime, + minMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; import { BatchCommand } from "@/commands"; @@ -22,13 +30,13 @@ import { registerCanceller } from "@/editor/cancel-interaction"; export interface KeyframeDragState { isDragging: boolean; draggingKeyframeIds: Set; - deltaTime: number; + deltaTime: MediaTime; } const initialDragState: KeyframeDragState = { isDragging: false, draggingKeyframeIds: new Set(), - deltaTime: 0, + deltaTime: ZERO_MEDIA_TIME, }; interface PendingKeyframeDrag { @@ -43,7 +51,7 @@ export function useKeyframeDrag({ }: { zoomLevel: number; element: TimelineElement; - displayedStartTime: number; + displayedStartTime: MediaTime; }) { const editor = useEditor(); const { @@ -83,7 +91,7 @@ export function useKeyframeDrag({ deltaTime, }: { keyframeRefs: SelectedKeyframeRef[]; - deltaTime: number; + deltaTime: MediaTime; }) => { const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { const keyframe = getKeyframeById({ @@ -92,10 +100,13 @@ export function useKeyframeDrag({ keyframeId: keyframeRef.keyframeId, }); if (!keyframe) return []; - const nextTime = Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTime), - ); + const nextTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ + a: element.duration, + b: addMediaTime({ a: keyframe.time, b: deltaTime }), + }), + }); return [ new RetimeKeyframeCommand({ trackId: keyframeRef.trackId, @@ -138,7 +149,7 @@ export function useKeyframeDrag({ draggingKeyframeIds: new Set( pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), ), - deltaTime: 0, + deltaTime: ZERO_MEDIA_TIME, }); return; } @@ -146,11 +157,14 @@ export function useKeyframeDrag({ if (!dragState.isDragging) return; const startX = mouseDownXRef.current ?? clientX; - const rawDelta = Math.round( - ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, - ); - const snappedDelta = - roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; + const rawDelta = mediaTime({ + ticks: Math.round( + ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, + ), + }); + const snappedDelta = ( + roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta + ) as MediaTime; setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); }; @@ -246,7 +260,7 @@ export function useKeyframeDrag({ event: ReactMouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }) => { event.stopPropagation(); @@ -259,12 +273,17 @@ export function useKeyframeDrag({ if (wasDrag) return; const duration = editor.timeline.getTotalDuration(); - const seekTime = + const absoluteIndicatorTime = addMediaTime({ + a: displayedStartTime, + b: indicatorTime, + }); + const seekTime = ( snappedSeekTime({ - time: displayedStartTime + indicatorTime, + time: absoluteIndicatorTime, duration, rate: fps, - }) ?? displayedStartTime + indicatorTime; + }) ?? absoluteIndicatorTime + ) as MediaTime; editor.playback.seek({ time: seekTime }); if (event.shiftKey) { diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 775b7337..5a892ccf 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -4,7 +4,7 @@ import { processMediaAssets } from "@/media/processing"; import { toast } from "sonner"; import { showMediaUploadToast } from "@/media/upload-toast"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { roundToFrame } from "opencut-wasm"; import { @@ -45,9 +45,9 @@ export function useTimelineDragDrop({ const [dragElementType, setElementType] = useState(null); const getSnappedTime = useCallback( - ({ time }: { time: number }) => { + ({ time }: { time: MediaTime }) => { const projectFps = editor.project.getActive().settings.fps; - return roundToFrame({ time, rate: projectFps }) ?? time; + return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime; }, [editor], ); @@ -76,7 +76,7 @@ export function useTimelineDragDrop({ }: { elementType: ElementType; mediaId?: string; - }): number => { + }): MediaTime => { if ( elementType === "text" || elementType === "graphic" || @@ -89,7 +89,7 @@ export function useTimelineDragDrop({ const mediaAssets = editor.media.getAssets(); const media = mediaAssets.find((m) => m.id === mediaId); return media?.duration != null - ? Math.round(media.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: media.duration }) : DEFAULT_NEW_ELEMENT_DURATION; } return DEFAULT_NEW_ELEMENT_DURATION; @@ -346,7 +346,7 @@ export function useTimelineDragDrop({ const duration = mediaAsset.duration != null - ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: mediaAsset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: mediaAsset.id, @@ -470,7 +470,7 @@ export function useTimelineDragDrop({ const duration = createdAsset.duration != null - ? Math.round(createdAsset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: createdAsset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const sceneTracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 7d9cf447..6fbf2147 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,5 +1,5 @@ import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { useEffect, useCallback, useRef } from "react"; import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; import { useEditor } from "@/editor/use-editor"; @@ -53,11 +53,11 @@ export function useTimelinePlayhead({ }, [zoomLevel, duration, isScrubbing, editor.playback]); const seek = useCallback( - ({ time }: { time: number }) => editor.playback.seek({ time }), + ({ time }: { time: MediaTime }) => editor.playback.seek({ time }), [editor.playback], ); - const scrubTimeRef = useRef(null); + const scrubTimeRef = useRef(null); const isDraggingRulerRef = useRef(false); const hasDraggedRulerRef = useRef(false); const lastMouseXRef = useRef(0); @@ -92,11 +92,14 @@ export function useTimelinePlayhead({ clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ), ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); + const rawTime = mediaTime({ + ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), + }); const rate = activeProject.settings.fps; - const frameTime = - snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; + const frameTime = ( + snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime + ) as MediaTime; const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const time = (() => { @@ -224,7 +227,7 @@ export function useTimelinePlayhead({ }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); const updatePlayheadLeft = useCallback( - (time: number) => { + (time: MediaTime) => { const playheadEl = playheadRef?.current; if (!playheadEl) return; const centerPosition = timelineTimeToSnappedPixels({ @@ -251,8 +254,7 @@ export function useTimelinePlayhead({ }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); useEffect(() => { - const handlePlaybackUpdate = (e: Event) => { - const time = (e as CustomEvent<{ time: number }>).detail.time; + const handlePlaybackTime = (time: MediaTime) => { updatePlayheadLeft(time); if (!isPlayingRef.current || isScrubbingRef.current) return; @@ -280,11 +282,12 @@ export function useTimelinePlayhead({ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; } }; + const handlePlaybackUpdate = (e: Event) => { + handlePlaybackTime((e as CustomEvent<{ time: MediaTime }>).detail.time); + }; const initialTime = editor.playback.getCurrentTime(); - handlePlaybackUpdate({ - detail: { time: initialTime }, - } as CustomEvent<{ time: number }>); + handlePlaybackTime(initialTime); window.addEventListener("playback-update", handlePlaybackUpdate); window.addEventListener("playback-seek", handlePlaybackUpdate); diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index fb8da291..accda4a1 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -139,7 +139,7 @@ export function useTimelineResize({ updates: result.updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - updates: patch, + updates: patch as Partial, })), }); }; @@ -155,7 +155,7 @@ export function useTimelineResize({ updates: result.updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - patch, + patch: patch as Partial, })), }); } diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 889177c9..88a8268d 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -2,7 +2,7 @@ import { useCallback, useRef } from "react"; import type { MutableRefObject, RefObject } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { useEditor } from "@/editor/use-editor"; interface UseTimelineSeekProps { @@ -11,10 +11,10 @@ interface UseTimelineSeekProps { rulerScrollRef: RefObject; tracksScrollRef: RefObject; zoomLevel: number; - duration: number; + duration: MediaTime; isSelecting: boolean; clearSelectedElements: () => void; - seek: (time: number) => void; + seek: (time: MediaTime) => void; } function resetMouseTracking({ @@ -135,11 +135,13 @@ export function useTimelineSeek({ (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ), ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); + const rawTime = mediaTime({ + ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), + }); const rate = activeProject?.settings.fps; const time = rate - ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) + ? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime) : rawTime; seek(time); editor.project.setTimelineViewState({ diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 7929d495..0f75face 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -12,13 +12,14 @@ import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale"; import { timelineTimeToPixels } from "@/timeline/pixel-utils"; import { useEditor } from "@/editor/use-editor"; import { zoomToSlider } from "@/timeline/zoom-utils"; +import type { MediaTime } from "@/wasm"; interface UseTimelineZoomProps { containerRef: RefObject; minZoom?: number; initialZoom?: number; initialScrollLeft?: number; - initialPlayheadTime?: number; + initialPlayheadTime?: MediaTime; tracksScrollRef: RefObject; rulerScrollRef: RefObject; } @@ -106,7 +107,7 @@ export function useTimelineZoom({ setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); return; } - setZoomLevel((prev) => { + setZoomLevel((prev: number) => { if (prev < minZoom) { return minZoom; } @@ -116,7 +117,7 @@ export function useTimelineZoom({ const wrappedSetZoomLevel = useCallback( (zoomLevelOrUpdater: number | ((prev: number) => number)) => { - setZoomLevel((prev) => { + setZoomLevel((prev: number) => { const nextZoom = typeof zoomLevelOrUpdater === "function" ? zoomLevelOrUpdater(prev) diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts index 5ee079b1..3775e006 100644 --- a/apps/web/src/timeline/index.ts +++ b/apps/web/src/timeline/index.ts @@ -1,29 +1,32 @@ -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 MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +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; +}): MediaTime { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + if (orderedTracks.length === 0) return ZERO_MEDIA_TIME; + + let maxEnd: MediaTime = ZERO_MEDIA_TIME; + for (const track of orderedTracks) { + for (const element of track.elements) { + // `startTime + duration` is integer-by-construction (both `MediaTime`), + // so the cast re-establishes the brand without runtime work. + const elementEnd = (element.startTime + element.duration) as MediaTime; + if (elementEnd > maxEnd) maxEnd = elementEnd; + } + } + return maxEnd; +} diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts index ebbfe578..44f0a1fe 100644 --- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts @@ -14,6 +14,7 @@ import type { } from "@/timeline"; import type { Transform } from "@/rendering"; import { resolveTrackPlacement } from "@/timeline/placement"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; function buildTransform(): Transform { return { @@ -67,10 +68,10 @@ function buildElement({ id, type: "audio", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, volume: 1, sourceType: "upload", mediaId: `media-${id}`, @@ -80,10 +81,10 @@ function buildElement({ id, type: "graphic", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, definitionId: `graphic-${id}`, params: {}, transform: buildTransform(), @@ -94,10 +95,10 @@ function buildElement({ id, type: "text", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, content: id, fontSize: 32, fontFamily: "sans-serif", @@ -118,10 +119,10 @@ function buildElement({ id, type: "video", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, mediaId: `media-${id}`, transform: buildTransform(), opacity: 1, @@ -224,7 +225,11 @@ function buildTimeSpan({ duration: number; excludeElementId?: string; }) { - return { startTime, duration, excludeElementId }; + return { + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + excludeElementId, + }; } function buildSceneTracks({ diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts index ab2ab7bd..36632226 100644 --- a/apps/web/src/timeline/placement/main-track.ts +++ b/apps/web/src/timeline/placement/main-track.ts @@ -1,4 +1,5 @@ import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export const MAIN_TRACK_NAME = "Main Track"; @@ -31,9 +32,9 @@ export function enforceMainTrackStart({ }: { tracks: SceneTracks; targetTrackId: string; - requestedStartTime: number; + requestedStartTime: MediaTime; excludeElementId?: string; -}): number { +}): MediaTime { if (tracks.main.id !== targetTrackId) { return requestedStartTime; } @@ -43,11 +44,11 @@ export function enforceMainTrackStart({ excludeElementId, }); if (!earliestElement) { - return 0; + return ZERO_MEDIA_TIME; } if (requestedStartTime <= earliestElement.startTime) { - return 0; + return ZERO_MEDIA_TIME; } return requestedStartTime; diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts index 26275392..680cb0bb 100644 --- a/apps/web/src/timeline/placement/resolve.ts +++ b/apps/web/src/timeline/placement/resolve.ts @@ -13,6 +13,7 @@ import type { PlacementSubject, PlacementTimeSpan, } from "./types"; +import { ZERO_MEDIA_TIME } from "@/wasm"; type ResolveTrackPlacementParams = PlacementSubject & { tracks: SceneTracks; @@ -32,7 +33,7 @@ function buildExistingTrackResult({ timeSpans: PlacementTimeSpan[]; }): PlacementResult { const firstSpan = timeSpans[0]; - const requestedStartTime = firstSpan?.startTime ?? 0; + const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME; const adjustedStartTime = enforceMainTrackStart({ tracks, targetTrackId: track.id, diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts index 6847ab36..748f34d7 100644 --- a/apps/web/src/timeline/placement/types.ts +++ b/apps/web/src/timeline/placement/types.ts @@ -1,8 +1,9 @@ import type { ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface PlacementTimeSpan { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; excludeElementId?: string; } @@ -29,7 +30,7 @@ export type PlacementResult = trackId: string; trackIndex: number; trackType: TrackType; - adjustedStartTime?: number; + adjustedStartTime?: MediaTime; } | { kind: "newTrack"; diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts index d48c943c..8aa1b6b5 100644 --- a/apps/web/src/timeline/scenes.ts +++ b/apps/web/src/timeline/scenes.ts @@ -2,6 +2,7 @@ import type { TScene } from "@/timeline"; import { generateUUID } from "@/utils/id"; import { calculateTotalDuration } from "@/timeline"; import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { return scenes.find((scene) => scene.isMain) || null; @@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({ scenes, }: { scenes: TScene[]; -}): number { +}): MediaTime { const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null; if (!mainScene?.tracks) { - return 0; + return ZERO_MEDIA_TIME; } return calculateTotalDuration({ tracks: mainScene.tracks }); diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index cd543cdf..2a30a065 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -3,6 +3,7 @@ import type { Effect } from "@/effects/types"; import type { Mask } from "@/masks/types"; import type { ParamValues } from "@/params"; import type { BlendMode, Transform } from "@/rendering"; +import type { MediaTime } from "@/wasm"; export type ElementRef = { trackId: string; @@ -10,10 +11,10 @@ export type ElementRef = { }; export interface Bookmark { - time: number; + time: MediaTime; note?: string; color?: string; - duration?: number; + duration?: MediaTime; } export interface TScene { @@ -107,11 +108,11 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement; interface BaseTimelineElement { id: string; name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; + duration: MediaTime; + startTime: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; animations?: ElementAnimations; } @@ -273,13 +274,13 @@ export interface ElementDragState { isDragging: boolean; elementId: string | null; dragElementIds: string[]; - dragTimeOffsets: Record; + dragTimeOffsets: Record; trackId: string | null; startMouseX: number; startMouseY: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; + startElementTime: MediaTime; + clickOffsetTime: MediaTime; + currentTime: MediaTime; currentMouseY: number; } @@ -287,7 +288,7 @@ export interface DropTarget { trackIndex: number; isNewTrack: boolean; insertPosition: "above" | "below" | null; - xPosition: number; + xPosition: MediaTime; targetElement: { elementId: string; trackId: string } | null; } @@ -296,13 +297,13 @@ export interface ComputeDropTargetParams { mouseX: number; mouseY: number; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; isExternalDrop: boolean; - elementDuration: number; + elementDuration: MediaTime; pixelsPerSecond: number; zoomLevel: number; verticalDragDirection?: "up" | "down" | null; - startTimeOverride?: number; + startTimeOverride?: MediaTime; excludeElementId?: string; targetElementTypes?: string[]; } diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts index beacf897..7ab129e9 100644 --- a/apps/web/src/timeline/update-pipeline.ts +++ b/apps/web/src/timeline/update-pipeline.ts @@ -6,6 +6,7 @@ import { } from "@/retime"; import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline"; import { isRetimableElement } from "@/timeline"; +import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm"; type ElementUpdateField = keyof TimelineElement | string; @@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [ 0, sourceDuration - element.trimStart - element.trimEnd, ); - const nextDuration = getTimelineDurationForSourceSpan({ - sourceSpan: visibleSourceSpan, - retime: nextRetime, + const nextDuration = roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }), }); return { @@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [ { triggers: ["startTime"], apply: ({ element, context }) => { - const requestedStartTime = Math.max(0, element.startTime); + const requestedStartTime = + element.startTime < ZERO_MEDIA_TIME + ? ZERO_MEDIA_TIME + : element.startTime; if (context.trackId !== context.tracks.main.id) { return { element: { @@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [ ...element, startTime: !earliestElement || requestedStartTime <= earliestElement.startTime - ? 0 + ? ZERO_MEDIA_TIME : requestedStartTime, }, }; diff --git a/apps/web/src/wasm/index.ts b/apps/web/src/wasm/index.ts index 60031a0e..c979b620 100644 --- a/apps/web/src/wasm/index.ts +++ b/apps/web/src/wasm/index.ts @@ -1 +1 @@ -export * from "./ticks"; +export * from "./media-time"; diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts new file mode 100644 index 00000000..7209e81c --- /dev/null +++ b/apps/web/src/wasm/media-time.ts @@ -0,0 +1,129 @@ +import { + TICKS_PER_SECOND as _TICKS_PER_SECOND, + mediaTimeFromSeconds as _mediaTimeFromSeconds, + mediaTimeToSeconds as _mediaTimeToSeconds, +} from "opencut-wasm"; + +/** + * Integer-tick time. Mirrors `MediaTime(i64)` in `rust/crates/time/src/media_time.rs`. + * + * `opencut-wasm` exposes `MediaTime` as a bare `number` alias because tsify + * collapses tuple structs. The brand here is the TS-side discipline that + * recovers the invariant: a `MediaTime` is an integer count of ticks, and the + * only legal way to construct one from a fractional `number` is `roundMediaTime` + * (or `mediaTimeFromSeconds`, which rounds inside the wasm boundary). + * + * Reading is free — `MediaTime` is assignable to `number`. Writing is gated — + * a bare `number` is not assignable to `MediaTime`. + */ +export type MediaTime = number & { readonly __mediaTime: unique symbol }; + +export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); + +export const ZERO_MEDIA_TIME = 0 as MediaTime; + +/** + * Construct a `MediaTime` from a known-integer tick count. Asserts in dev that + * the value is actually an integer; cast in release. Use `roundMediaTime` when + * the input may be fractional. + */ +export function mediaTime({ ticks }: { ticks: number }): MediaTime { + if (process.env.NODE_ENV !== "production" && !Number.isInteger(ticks)) { + throw new Error( + `mediaTime() requires an integer tick count, got ${ticks}. Use roundMediaTime() for fractional values.`, + ); + } + return ticks as MediaTime; +} + +/** + * Project a fractional value onto the integer-tick lattice. + * + * Rounds half away from zero (`-1.5 → -2`, `1.5 → 2`) and normalises `-0` to + * `0`. The away-from-zero rule matches Rust's `.round()` and avoids the + * `Math.round(-0.5) === -0` quirk that propagates `-0` into stored data. + */ +export function roundMediaTime({ time }: { time: number }): MediaTime { + const roundedMagnitude = Math.round(Math.abs(time)); + if (roundedMagnitude === 0) { + return 0 as MediaTime; + } + return (time < 0 ? -roundedMagnitude : roundedMagnitude) as MediaTime; +} + +export function mediaTimeFromSeconds({ + seconds, +}: { + seconds: number; +}): MediaTime { + const result = _mediaTimeFromSeconds({ seconds }); + if (result === undefined) { + throw new Error( + `mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`, + ); + } + return result as MediaTime; +} + +export function mediaTimeToSeconds({ time }: { time: MediaTime }): number { + return _mediaTimeToSeconds({ time }); +} + +/** + * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too; + * the cast is a no-op at runtime, only re-establishes the brand for the type + * system. + */ +export function addMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return (a + b) as MediaTime; +} + +export function subMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return (a - b) as MediaTime; +} + +export function maxMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return (a > b ? a : b) as MediaTime; +} + +export function minMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return (a < b ? a : b) as MediaTime; +} + +export function clampMediaTime({ + time, + min, + max, +}: { + time: MediaTime; + min: MediaTime; + max: MediaTime; +}): MediaTime { + if (time < min) return min; + if (time > max) return max; + return time; +} diff --git a/apps/web/src/wasm/ticks.ts b/apps/web/src/wasm/ticks.ts deleted file mode 100644 index ababe6b3..00000000 --- a/apps/web/src/wasm/ticks.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm"; - -export const TICKS_PER_SECOND = _TICKS_PER_SECOND();