summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/hooks/element
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/element
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/element')
-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
2 files changed, 84 insertions, 43 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) {