diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-07 01:09:13 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-07 01:09:13 +0200 |
| commit | e4b67094e757917588fb624a30aa046667403add (patch) | |
| tree | 55452692eac69a5a63b58f2a5cdb701dd7023d68 /apps/web/src/components/editor | |
| parent | 79df73643185c02493364a077402726d1fa7b9ec (diff) | |
| download | opencut-e4b67094e757917588fb624a30aa046667403add.tar.gz | |
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'apps/web/src/components/editor')
12 files changed, 116 insertions, 103 deletions
diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx index c31c8e96..ed4eb990 100644 --- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -8,7 +8,7 @@ import { } from "@/components/ui/dialog"; import type { TProjectMetadata } from "@/lib/project/types"; import { formatDate } from "@/utils/date"; -import { formatTimeCode } from "opencut-wasm"; +import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm"; import { Button } from "@/components/ui/button"; function InfoRow({ @@ -35,9 +35,10 @@ export function ProjectInfoDialog({ onOpenChange: (open: boolean) => void; project: TProjectMetadata; }) { + const durationSeconds = mediaTimeToSeconds({ time: project.duration }); const durationFormatted = project.duration > 0 - ? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "") + ? (formatTimecode({ time: project.duration, format: durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "") : "0:00"; return ( 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` }} + /> + ); +} |
