summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
commite4b67094e757917588fb624a30aa046667403add (patch)
tree55452692eac69a5a63b58f2a5cdb701dd7023d68 /apps/web/src/hooks
parent79df73643185c02493364a077402726d1fa7b9ec (diff)
downloadopencut-e4b67094e757917588fb624a30aa046667403add.tar.gz
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'apps/web/src/hooks')
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts7
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts30
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts42
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts11
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts10
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts16
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts42
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-seek.ts27
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts8
-rw-r--r--apps/web/src/hooks/use-paste-media.ts228
10 files changed, 218 insertions, 203 deletions
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index 76f705e9..599b4260 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -5,6 +5,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import {
getElementsAtTime,
@@ -110,10 +111,11 @@ 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() + 1 / fps,
+ editor.playback.getCurrentTime() + ticksPerFrame,
),
});
},
@@ -124,8 +126,9 @@ export function useEditorActions() {
"frame-step-backward",
() => {
const fps = editor.project.getActive().settings.fps;
+ const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
+ time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
});
},
undefined,
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index 71ae4704..6fcd4168 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -10,8 +10,9 @@ import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
-import { snapTimeToFrame } from "opencut-wasm";
+import { roundToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@@ -67,7 +68,8 @@ function getClickOffsetTime({
zoomLevel: number;
}): number {
const clickOffsetX = clientX - elementRect.left;
- return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
+ const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
+ return Math.round(seconds * TICKS_PER_SECOND);
}
function getVerticalDragDirection({
@@ -297,15 +299,17 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
- const adjustedTime =
- mouseTime - pendingDragRef.current.clickOffsetTime;
- const snappedTime = snapTimeToFrame({
- time: adjustedTime,
- fps: activeProject.settings.fps,
- });
- startDrag({
- ...pendingDragRef.current,
- initialCurrentTime: snappedTime,
+ const adjustedTime = Math.max(
+ 0,
+ mouseTime - pendingDragRef.current.clickOffsetTime,
+ );
+ const snappedTime = roundToFrame({
+ time: adjustedTime,
+ rate: activeProject.settings.fps,
+ }) ?? adjustedTime;
+ startDrag({
+ ...pendingDragRef.current,
+ initialCurrentTime: snappedTime,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
@@ -343,9 +347,9 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
- const adjustedTime = mouseTime - dragState.clickOffsetTime;
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
- const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps });
+ const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 3dd98bea..dec87eef 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { snapTimeToFrame } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+import { roundToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
@@ -185,13 +186,14 @@ export function useTimelineElementResize({
({ clientX }: { clientX: number }) => {
if (!resizing) return;
- const deltaX = clientX - resizing.startX;
- let deltaTime =
- deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
- let resizeSnapPoint: SnapPoint | null = null;
+ const deltaX = clientX - resizing.startX;
+ let deltaTime = Math.round(
+ (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
+ );
+ let resizeSnapPoint: SnapPoint | null = null;
- const projectFps = editor.project.getActive().settings.fps;
- const minDurationSeconds = 1 / projectFps;
+ const projectFps = editor.project.getActive().settings.fps;
+ const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
const tracks = editor.timeline.getTracks();
@@ -277,19 +279,19 @@ export function useTimelineElementResize({
const maxAllowed =
sourceDuration -
resizing.initialTrimEnd -
- getVisibleSourceSpanForDuration(minDurationSeconds);
+ getVisibleSourceSpanForDuration(minDuration);
const calculated =
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
- const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
+ const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated));
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
- const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
+ const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const trimDelta = resizing.initialDuration - newDuration;
- const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
+ const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta;
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -311,8 +313,8 @@ export function useTimelineElementResize({
)
: Math.min(extensionAmount, maxExtension),
);
- const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
- const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
+ const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
+ const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
@@ -338,8 +340,8 @@ export function useTimelineElementResize({
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
- const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
- const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
+ const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
+ const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -366,7 +368,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
- const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
+ const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -376,7 +378,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
- const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
+ const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -395,17 +397,17 @@ export function useTimelineElementResize({
const maxTrimEnd =
sourceDuration -
resizing.initialTrimStart -
- getVisibleSourceSpanForDuration(minDurationSeconds);
+ getVisibleSourceSpanForDuration(minDuration);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
- const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
+ const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
- const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
+ const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
index b66bbea8..24bff26a 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -8,9 +8,10 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { getKeyframeById } from "@/lib/animation";
import { useKeyframeSelection } from "./use-keyframe-selection";
-import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
+import { roundToFrame, snappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
@@ -144,9 +145,9 @@ export function useKeyframeDrag({
if (!dragState.isDragging) return;
- const startX = mouseDownXRef.current ?? clientX;
- const rawDelta = (clientX - startX) / pixelsPerSecond;
- const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
+ const startX = mouseDownXRef.current ?? clientX;
+ const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND);
+ const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
};
@@ -255,7 +256,7 @@ export function useKeyframeDrag({
if (wasDrag) return;
const duration = editor.timeline.getTotalDuration();
- const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
+ const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime;
editor.playback.seek({ time: seekTime });
if (event.shiftKey) {
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index 14d58d9a..90edbede 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -8,7 +8,7 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
-import { snapTimeToFrame } from "opencut-wasm";
+import { roundToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
findSnapPoints,
@@ -148,7 +148,7 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
- const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
+ const frameSnappedTime = roundToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), rate: activeProject.settings.fps }) ?? Math.max(0, Math.min(mouseTime, duration));
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
@@ -176,9 +176,9 @@ export function useBookmarkDrag({
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
- const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
- const snapResult = getSnapResult({
- rawTime: frameSnappedTime,
+ const frameSnappedTime = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime;
+ const snapResult = getSnapResult({
+ rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
});
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 8e87510c..7b9ee2ee 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -3,9 +3,9 @@ import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
-import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { snapTimeToFrame } from "opencut-wasm";
+import { roundToFrame } from "opencut-wasm";
import {
buildTextElement,
buildGraphicElement,
@@ -47,7 +47,7 @@ export function useTimelineDragDrop({
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
const projectFps = editor.project.getActive().settings.fps;
- return snapTimeToFrame({ time, fps: projectFps });
+ return roundToFrame({ time, rate: projectFps }) ?? time;
},
[editor],
);
@@ -83,14 +83,14 @@ export function useTimelineDragDrop({
elementType === "sticker" ||
elementType === "effect"
) {
- return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
+ return DEFAULT_NEW_ELEMENT_DURATION;
}
if (mediaId) {
const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
- return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
+ return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
}
- return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
+ return DEFAULT_NEW_ELEMENT_DURATION;
},
[editor],
);
@@ -331,7 +331,7 @@ export function useTimelineDragDrop({
dragData.mediaType === "audio" ? "audio" : "video";
const duration =
- mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
+ mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: mediaAsset.id,
mediaType: mediaAsset.type,
@@ -446,7 +446,7 @@ export function useTimelineDragDrop({
const duration =
createdAsset.duration ??
- DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
+ DEFAULT_NEW_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index fce84135..70364e90 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -1,4 +1,5 @@
-import { getSnappedSeekTime } from "opencut-wasm";
+import { snappedSeekTime } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
@@ -6,6 +7,7 @@ import { useShiftKey } from "@/hooks/use-shift-key";
import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
import {
getCenteredLineLeft,
+ timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
@@ -66,24 +68,24 @@ export function useTimelinePlayhead({
const rulerRect = ruler.getBoundingClientRect();
const relativeMouseX = event.clientX - rulerRect.left;
- const timelineContentWidth =
- duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+ const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
- const clampedMouseX = Math.max(
- 0,
- Math.min(timelineContentWidth, relativeMouseX),
- );
+ const clampedMouseX = Math.max(
+ 0,
+ Math.min(timelineContentWidth, relativeMouseX),
+ );
- const rawTime = Math.max(
- 0,
- Math.min(
- duration,
- clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- ),
- );
+ const rawTimeSeconds = Math.max(
+ 0,
+ Math.min(
+ duration / TICKS_PER_SECOND,
+ clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+ const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
- const framesPerSecond = activeProject.settings.fps;
- const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
+ const rate = activeProject.settings.fps;
+ const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
@@ -161,7 +163,7 @@ export function useTimelinePlayhead({
getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef,
tracksScrollRef,
- contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
+ contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
});
useEffect(() => {
@@ -247,8 +249,10 @@ export function useTimelinePlayhead({
const tracksViewport = tracksScrollRef.current;
if (!rulerViewport || !tracksViewport) return;
- const playheadPixels =
- time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
+ const playheadPixels = timelineTimeToPixels({
+ time,
+ zoomLevel: zoomLevelRef.current,
+ });
const viewportWidth = rulerViewport.clientWidth;
const scrollMinimum = 0;
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts
index 1690153e..756ec7ee 100644
--- a/apps/web/src/hooks/timeline/use-timeline-seek.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts
@@ -1,7 +1,8 @@
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { getSnappedSeekTime } from "opencut-wasm";
+import { snappedSeekTime } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
@@ -127,17 +128,18 @@ export function useTimelineSeek({
const mouseX = event.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
- const rawTime = Math.max(
- 0,
- Math.min(
- duration,
- (mouseX + scrollLeft) /
- (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- ),
- );
+ const rawTimeSeconds = Math.max(
+ 0,
+ Math.min(
+ duration / TICKS_PER_SECOND,
+ (mouseX + scrollLeft) /
+ (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+ const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
- const projectFps = activeProject?.settings.fps || 30;
- const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
+ const rate = activeProject?.settings.fps;
+ const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime;
seek(time);
editor.project.setTimelineViewState({
viewState: {
@@ -154,7 +156,8 @@ export function useTimelineSeek({
tracksScrollRef,
seek,
editor,
- activeProject?.settings.fps,
+ activeProject?.settings.fps.numerator,
+ activeProject?.settings.fps.denominator,
],
);
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
index a1740384..1f79a5b2 100644
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -13,8 +13,8 @@ import {
import {
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
- BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
+import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
@@ -186,10 +186,8 @@ export function useTimelineZoom({
}
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
- const playheadPixelsBefore =
- playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
- const playheadPixelsAfter =
- playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+ const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom });
+ const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel });
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts
index 1cba34c0..36d70ebe 100644
--- a/apps/web/src/hooks/use-paste-media.ts
+++ b/apps/web/src/hooks/use-paste-media.ts
@@ -1,114 +1,114 @@
-import { useEffect } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { processMediaAssets } from "@/lib/media/processing";
-import { showMediaUploadToast } from "@/lib/media/upload-toast";
-import { invokeAction } from "@/lib/actions";
-import { buildElementFromMedia } from "@/lib/timeline/element-utils";
-import { AddMediaAssetCommand } from "@/lib/commands/media";
-import { InsertElementCommand } from "@/lib/commands/timeline";
-import { BatchCommand } from "@/lib/commands";
-import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
-import { isTypableDOMElement } from "@/utils/browser";
-import type { MediaType } from "@/lib/media/types";
-
-const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
-
-function isMediaMimeType({ type }: { type: string }): boolean {
- return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
-}
-
-function extractMediaFilesFromClipboard({
- clipboardData,
-}: {
- clipboardData: DataTransfer | null;
-}): File[] {
- if (!clipboardData?.items) return [];
-
- const files: File[] = [];
- for (const item of clipboardData.items) {
- if (item.kind !== "file") continue;
- if (!isMediaMimeType({ type: item.type })) continue;
-
- const file = item.getAsFile();
- if (file) files.push(file);
- }
- return files;
-}
-
-export function usePasteMedia() {
- const editor = useEditor();
-
- useEffect(() => {
- const handlePaste = async (event: ClipboardEvent) => {
- const activeElement = document.activeElement as HTMLElement;
-
- if (activeElement && isTypableDOMElement({ element: activeElement })) {
- return;
- }
-
- const files = extractMediaFilesFromClipboard({
- clipboardData: event.clipboardData,
- });
- if (files.length === 0) {
- event.preventDefault();
- invokeAction("paste-copied");
- return;
- }
-
- event.preventDefault();
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- try {
- await showMediaUploadToast({
- filesCount: files.length,
- promise: async () => {
- const processedAssets = await processMediaAssets({ files });
- const startTime = editor.playback.getCurrentTime();
-
- for (const asset of processedAssets) {
- const addMediaCmd = new AddMediaAssetCommand(
- activeProject.metadata.id,
- asset,
- );
- const assetId = addMediaCmd.getAssetId();
- const duration =
- asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
- const trackType = asset.type === "audio" ? "audio" : "video";
-
- const element = buildElementFromMedia({
- mediaId: assetId,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime,
- buffer:
- asset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
-
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "auto", trackType },
- });
- const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
- editor.command.execute({ command: batchCmd });
- }
-
- return {
- uploadedCount: processedAssets.length,
- assetNames: processedAssets.map((asset) => asset.name),
- };
- },
- });
- } catch (error) {
- console.error("Failed to paste media:", error);
- }
- };
-
- window.addEventListener("paste", handlePaste);
- return () => window.removeEventListener("paste", handlePaste);
- }, [editor]);
-}
+import { useEffect } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { processMediaAssets } from "@/lib/media/processing";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import { invokeAction } from "@/lib/actions";
+import { buildElementFromMedia } from "@/lib/timeline/element-utils";
+import { AddMediaAssetCommand } from "@/lib/commands/media";
+import { InsertElementCommand } from "@/lib/commands/timeline";
+import { BatchCommand } from "@/lib/commands";
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
+import { isTypableDOMElement } from "@/utils/browser";
+import type { MediaType } from "@/lib/media/types";
+
+const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
+
+function isMediaMimeType({ type }: { type: string }): boolean {
+ return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
+}
+
+function extractMediaFilesFromClipboard({
+ clipboardData,
+}: {
+ clipboardData: DataTransfer | null;
+}): File[] {
+ if (!clipboardData?.items) return [];
+
+ const files: File[] = [];
+ for (const item of clipboardData.items) {
+ if (item.kind !== "file") continue;
+ if (!isMediaMimeType({ type: item.type })) continue;
+
+ const file = item.getAsFile();
+ if (file) files.push(file);
+ }
+ return files;
+}
+
+export function usePasteMedia() {
+ const editor = useEditor();
+
+ useEffect(() => {
+ const handlePaste = async (event: ClipboardEvent) => {
+ const activeElement = document.activeElement as HTMLElement;
+
+ if (activeElement && isTypableDOMElement({ element: activeElement })) {
+ return;
+ }
+
+ const files = extractMediaFilesFromClipboard({
+ clipboardData: event.clipboardData,
+ });
+ if (files.length === 0) {
+ event.preventDefault();
+ invokeAction("paste-copied");
+ return;
+ }
+
+ event.preventDefault();
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ try {
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const startTime = editor.playback.getCurrentTime();
+
+ for (const asset of processedAssets) {
+ const addMediaCmd = new AddMediaAssetCommand(
+ activeProject.metadata.id,
+ asset,
+ );
+ const assetId = addMediaCmd.getAssetId();
+ const duration =
+ asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
+ const trackType = asset.type === "audio" ? "audio" : "video";
+
+ const element = buildElementFromMedia({
+ mediaId: assetId,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ buffer:
+ asset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
+
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "auto", trackType },
+ });
+ const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
+ editor.command.execute({ command: batchCmd });
+ }
+
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
+ } catch (error) {
+ console.error("Failed to paste media:", error);
+ }
+ };
+
+ window.addEventListener("paste", handlePaste);
+ return () => window.removeEventListener("paste", handlePaste);
+ }, [editor]);
+}