summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/hooks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-26 02:50:04 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-26 02:50:04 +0200
commiteea6d43c82e1fd09d94d73001090dad205b1f1d2 (patch)
tree384c92e467effb708bcc96e25117c064b18464b8 /apps/web/src/timeline/hooks
parentf1b45b4328cff6a016720ce7b38b87ac2992dc2d (diff)
downloadopencut-eea6d43c82e1fd09d94d73001090dad205b1f1d2.tar.gz
fix: model editor time as wasm MediaTime ticks end-to-end
Diffstat (limited to 'apps/web/src/timeline/hooks')
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-interaction.ts70
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts57
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts14
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts27
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts4
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts12
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts7
7 files changed, 119 insertions, 72 deletions
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<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
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<string, number> = {};
+ const dragTimeOffsets: Record<string, MediaTime> = {};
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<string>;
- 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<ElementType | null>(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<number | null>(null);
+ const scrubTimeRef = useRef<MediaTime | null>(null);
const isDraggingRulerRef = useRef(false);
const hasDraggedRulerRef = useRef(false);
const lastMouseXRef = useRef<number>(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<TimelineElement>,
})),
});
};
@@ -155,7 +155,7 @@ export function useTimelineResize({
updates: result.updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
- patch,
+ patch: patch as Partial<TimelineElement>,
})),
});
}
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<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
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<HTMLDivElement | null>;
minZoom?: number;
initialZoom?: number;
initialScrollLeft?: number;
- initialPlayheadTime?: number;
+ initialPlayheadTime?: MediaTime;
tracksScrollRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
}
@@ -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)