From eea6d43c82e1fd09d94d73001090dad205b1f1d2 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 02:50:04 +0200 Subject: fix: model editor time as wasm MediaTime ticks end-to-end --- .../hooks/element/use-element-interaction.ts | 70 ++++++++++++++-------- .../timeline/hooks/element/use-keyframe-drag.ts | 57 ++++++++++++------ .../src/timeline/hooks/use-timeline-drag-drop.ts | 14 ++--- .../src/timeline/hooks/use-timeline-playhead.ts | 27 +++++---- apps/web/src/timeline/hooks/use-timeline-resize.ts | 4 +- apps/web/src/timeline/hooks/use-timeline-seek.ts | 12 ++-- apps/web/src/timeline/hooks/use-timeline-zoom.ts | 7 ++- 7 files changed, 119 insertions(+), 72 deletions(-) (limited to 'apps/web/src/timeline/hooks') 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) -- cgit v1.3.1