summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels
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/components/editor/panels
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/components/editor/panels')
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx13
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx11
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx4
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts45
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx3
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx9
-rw-r--r--apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts7
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx6
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx31
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx81
11 files changed, 113 insertions, 101 deletions
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 031d2e6a..85888dce 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -25,7 +25,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { invokeAction } from "@/lib/actions";
@@ -257,7 +257,7 @@ function MediaAssetDraggable({
startTime: number;
}) => {
const duration =
- asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
+ asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: asset.id,
mediaType: asset.type,
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
index 6c819a36..16faad3d 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -10,6 +10,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { FPS_PRESETS } from "@/lib/fps/constants";
+import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
@@ -232,12 +233,12 @@ export function SettingsView() {
<Section showTopBorder={false}>
<SectionHeader className="justify-between">
<SectionTitle className="flex-1">Frame rate</SectionTitle>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={(value) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- }}
+ <Select
+ value={String(Math.round(frameRateToFloat(activeProject.settings.fps)))}
+ onValueChange={(value) => {
+ const fps = floatToFrameRate(parseFloat(value));
+ editor.project.updateSettings({ settings: { fps } });
+ }}
>
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
<SelectValue placeholder="Select a frame rate" />
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index 8cfe7f49..786d9a46 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -7,6 +7,7 @@ import { useRafLoop } from "@/hooks/use-raf-loop";
import { useContainerSize } from "@/hooks/use-container-size";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
@@ -129,12 +130,16 @@ function PreviewCanvas({
height: nativeHeight,
fps: activeProject.settings.fps,
});
- }, [nativeWidth, nativeHeight, activeProject.settings.fps]);
+ }, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]);
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
- const renderTime = editor.playback.getCurrentTime();
- const frame = Math.floor(renderTime * renderer.fps);
+ const renderTime = Math.min(
+ editor.playback.getCurrentTime(),
+ editor.timeline.getLastFrameTime(),
+ );
+ const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator);
+ const frame = Math.floor(renderTime / ticksPerFrame);
if (
frame !== lastFrameRef.current ||
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
index 0be1ec46..555cde17 100644
--- a/apps/web/src/components/editor/panels/preview/toolbar.tsx
+++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
-import { formatTimeCode } from "opencut-wasm";
+import { formatTimecode } from "opencut-wasm";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
@@ -93,7 +93,7 @@ function TimecodeDisplay() {
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
- {formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
+ {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
</span>
</div>
);
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 a900cb13..27300784 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,23 +1,22 @@
-import { useEditor } from "@/hooks/use-editor";
-import { getElementLocalTime } from "@/lib/animation";
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
-
-export function useElementPlayhead({
- startTime,
- duration,
-}: {
- startTime: number;
- duration: number;
-}) {
- const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
- const localTime = getElementLocalTime({
- timelineTime: playheadTime,
- elementStartTime: startTime,
- elementDuration: duration,
- });
- const isPlayheadWithinElementRange =
- playheadTime >= startTime - TIME_EPSILON_SECONDS &&
- playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
-
- return { localTime, isPlayheadWithinElementRange };
-}
+import { useEditor } from "@/hooks/use-editor";
+import { getElementLocalTime } from "@/lib/animation";
+
+export function useElementPlayhead({
+ startTime,
+ duration,
+}: {
+ startTime: number;
+ duration: number;
+}) {
+ const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: startTime,
+ elementDuration: duration,
+ });
+ const isPlayheadWithinElementRange =
+ playheadTime >= startTime &&
+ playheadTime <= startTime + duration;
+
+ return { localTime, isPlayheadWithinElementRange };
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
index f48a50cb..9b0ffc88 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -15,7 +15,6 @@ import {
PlusSignIcon,
RotateClockwiseIcon,
} from "@hugeicons/core-free-icons";
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
@@ -102,7 +101,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
const elementBounds = useMemo(() => {
const clampedTime = Math.min(
Math.max(currentTime, element.startTime),
- element.startTime + element.duration - TIME_EPSILON_SECONDS,
+ element.startTime + element.duration - 1,
);
return (
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index d4b1657e..9342c30e 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -4,13 +4,12 @@ import { useEffect, useState } from "react";
import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
-import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import {
DEFAULT_TIMELINE_BOOKMARK_COLOR,
} from "./theme";
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/constants";
-import { getSnappedSeekTime } from "opencut-wasm";
+import { snappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
@@ -49,8 +48,8 @@ function seekToBookmarkTime({
}) {
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
- const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
- const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
+ const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
+ const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
editor.playback.seek({ time: snappedTime });
}
@@ -139,7 +138,7 @@ function TimelineBookmark({
const isDragging =
dragState.isDragging &&
dragState.bookmarkTime !== null &&
- Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
+ dragState.bookmarkTime === bookmark.time;
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
const time = bookmark.time;
diff --git a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
index 6488e29a..551393e9 100644
--- a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
+++ b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
@@ -1,5 +1,5 @@
import type { TimelineTrack } from "@/lib/timeline";
-import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
+import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import {
TIMELINE_CONTENT_TOP_PADDING_PX,
} from "./layout";
@@ -96,7 +96,6 @@ export function resolveTimelineElementIntersections({
startPos,
endPos: currentPos,
});
- const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
const selectedElements: TimelineElementRef[] = [];
for (const [trackIndex, track] of tracks.entries()) {
@@ -109,8 +108,8 @@ export function resolveTimelineElementIntersections({
const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
- const elementLeft = element.startTime * pixelsPerSecond;
- const elementRight = elementLeft + element.duration * pixelsPerSecond;
+ const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel });
+ const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel });
const elementRectangle = {
left: elementLeft,
top: elementTop,
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
index 361f1398..d9218371 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -7,6 +7,7 @@ import {
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "@/hooks/use-editor";
import {
TIMELINE_SCROLLBAR_SIZE_PX,
@@ -72,10 +73,11 @@ export function TimelinePlayhead({
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
- const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
+ const fps = editor.project.getActive().settings.fps;
+ const ticksPerFrame = 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 * step));
+ const nextTime = Math.max(0, Math.min(duration, now + direction * ticksPerFrame));
editor.playback.seek({ time: nextTime });
};
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
index 9e49bf6c..8b8dab32 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
@@ -2,6 +2,8 @@ import { type JSX, useLayoutEffect, useRef } from "react";
import {
BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
+import { mediaTimeToSeconds } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/constants";
import { useEditor } from "@/hooks/use-editor";
@@ -30,17 +32,18 @@ export function TimelineRuler({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineRulerProps) {
- const duration = useEditor((e) => e.timeline.getTotalDuration());
+ const durationTicks = useEditor((e) => e.timeline.getTotalDuration());
+ const durationSeconds = mediaTimeToSeconds({ time: durationTicks });
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
- const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
- const effectiveDuration = Math.max(duration, visibleDuration);
+ const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond;
+ const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds);
const fps =
useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
zoomLevel,
fps,
});
- const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
+ const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1;
const { scrollLeft, viewportWidth } = useScrollPosition({
scrollRef: tracksScrollRef,
@@ -61,20 +64,20 @@ export function TimelineRuler({
prevZoomRef.current = zoomLevel;
}, [zoomLevel]);
- const visibleStartTime = Math.max(
+ const visibleStartTimeSeconds = Math.max(
0,
(scrollLeft - bufferPx) / pixelsPerSecond,
);
- const visibleEndTime =
+ const visibleEndTimeSeconds =
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
const startTickIndex = Math.max(
0,
- Math.floor(visibleStartTime / tickIntervalSeconds),
+ Math.floor(visibleStartTimeSeconds / tickIntervalSeconds),
);
const endTickIndex = Math.min(
tickCount - 1,
- Math.ceil(visibleEndTime / tickIntervalSeconds),
+ Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds),
);
const timelineTicks: Array<JSX.Element> = [];
@@ -83,14 +86,16 @@ export function TimelineRuler({
tickIndex <= endTickIndex;
tickIndex += 1
) {
- const time = tickIndex * tickIntervalSeconds;
- if (time > effectiveDuration) break;
+ const timeSeconds = tickIndex * tickIntervalSeconds;
+ if (timeSeconds > effectiveDurationSeconds) break;
- const showLabel = shouldShowLabel({ time, labelIntervalSeconds });
+ const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND);
+ const showLabel = shouldShowLabel({ time: timeSeconds, labelIntervalSeconds });
timelineTicks.push(
<TimelineTick
key={tickIndex}
- time={time}
+ time={timeTicks}
+ timeInSeconds={timeSeconds}
zoomLevel={zoomLevel}
fps={fps}
showLabel={showLabel}
@@ -104,7 +109,7 @@ export function TimelineRuler({
tabIndex={0}
aria-label="Timeline ruler"
aria-valuemin={0}
- aria-valuemax={effectiveDuration}
+ aria-valuemax={effectiveDurationSeconds}
aria-valuenow={0}
className="relative flex-1 overflow-x-visible"
style={{ height: TIMELINE_RULER_HEIGHT_PX }}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
index 7d9a4bc2..fca55752 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
@@ -1,39 +1,42 @@
-"use client";
-
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
-
-interface TimelineTickProps {
- time: number;
- zoomLevel: number;
- fps: number;
- showLabel: boolean;
-}
-
-export function TimelineTick({
- time,
- zoomLevel,
- fps,
- showLabel,
-}: TimelineTickProps) {
- const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
-
- if (showLabel) {
- const label = formatRulerLabel({ timeInSeconds: time, fps });
- return (
- <span
- className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
- style={{ left: `${leftPosition}px` }}
- >
- {label}
- </span>
- );
- }
-
- return (
- <div
- className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
- style={{ left: `${leftPosition}px` }}
- />
- );
-}
+"use client";
+
+import type { FrameRate } from "opencut-wasm";
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
+import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
+
+interface TimelineTickProps {
+ time: number;
+ timeInSeconds: number;
+ zoomLevel: number;
+ fps: FrameRate;
+ showLabel: boolean;
+}
+
+export function TimelineTick({
+ time,
+ timeInSeconds,
+ zoomLevel,
+ fps,
+ showLabel,
+}: TimelineTickProps) {
+ const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
+
+ if (showLabel) {
+ const label = formatRulerLabel({ timeInSeconds, fps });
+ return (
+ <span
+ className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
+ style={{ left: `${leftPosition}px` }}
+ >
+ {label}
+ </span>
+ );
+ }
+
+ return (
+ <div
+ className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
+ style={{ left: `${leftPosition}px` }}
+ />
+ );
+}