From cceb835a84c125990ad8e49784e01a7c52636bb3 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 22 Apr 2026 16:48:37 +0200 Subject: [PATCH 01/29] perf: improve rendering performance by ~20x --- apps/web/src/diagnostics/render-perf.ts | 182 +++++++++ apps/web/src/preview/components/index.tsx | 102 ++--- .../src/services/renderer/canvas-renderer.ts | 45 ++- .../renderer/compositor/frame-descriptor.ts | 368 +++++++++--------- .../src/services/renderer/compositor/types.ts | 36 ++ .../renderer/compositor/wasm-compositor.ts | 280 ++++++++----- rust/crates/gpu/src/context.rs | 10 +- rust/wasm/Cargo.toml | 2 +- rust/wasm/src/compositor.rs | 57 ++- rust/wasm/src/perf.rs | 51 +++ rust/wasm/src/wasm.rs | 4 + 11 files changed, 780 insertions(+), 357 deletions(-) create mode 100644 apps/web/src/diagnostics/render-perf.ts create mode 100644 rust/wasm/src/perf.rs diff --git a/apps/web/src/diagnostics/render-perf.ts b/apps/web/src/diagnostics/render-perf.ts new file mode 100644 index 00000000..7b0b7fd9 --- /dev/null +++ b/apps/web/src/diagnostics/render-perf.ts @@ -0,0 +1,182 @@ +/** + * Lightweight rolling perf instrumentation for the render pipeline. + * + * Toggle at runtime from the devtools console: + * window.__renderPerf = true + * + * Every FLUSH_EVERY frames the aggregator dumps: + * - per-span timing summary (count / mean / p50 / p95 / max, in ms) + * - per-counter totals (uploads, canvas allocations by kind, etc.) + * + * Zero overhead when disabled: `isRenderPerfEnabled()` short-circuits before + * any recording happens, so call sites only pay for a global read. + */ + +type SpanSample = number; + +type SpanStats = { + samples: SpanSample[]; +}; + +type CounterStats = { + total: number; + frames: number; +}; + +const FLUSH_EVERY = 60; + +const spans = new Map(); +const counters = new Map(); +const pendingCountersThisFrame = new Map(); + +let framesSinceFlush = 0; + +declare global { + interface Window { + __renderPerf?: boolean; + } +} + +export function isRenderPerfEnabled(): boolean { + return typeof window !== "undefined" && window.__renderPerf === true; +} + +export function recordSpan({ + name, + durationMs, +}: { + name: string; + durationMs: number; +}): void { + if (!isRenderPerfEnabled()) return; + let stats = spans.get(name); + if (!stats) { + stats = { samples: [] }; + spans.set(name, stats); + } + stats.samples.push(durationMs); +} + +export async function measureSpanAsync({ + name, + fn, +}: { + name: string; + fn: () => Promise; +}): Promise { + if (!isRenderPerfEnabled()) return fn(); + const start = performance.now(); + try { + return await fn(); + } finally { + recordSpan({ name, durationMs: performance.now() - start }); + } +} + +export function measureSpanSync({ + name, + fn, +}: { + name: string; + fn: () => T; +}): T { + if (!isRenderPerfEnabled()) return fn(); + const start = performance.now(); + try { + return fn(); + } finally { + recordSpan({ name, durationMs: performance.now() - start }); + } +} + +export function incrementCounter({ + name, + by = 1, +}: { + name: string; + by?: number; +}): void { + if (!isRenderPerfEnabled()) return; + pendingCountersThisFrame.set( + name, + (pendingCountersThisFrame.get(name) ?? 0) + by, + ); +} + +/** + * Pulls sub-span timings recorded inside the wasm `renderFrame` call and + * feeds them into the aggregator as ordinary spans. + */ +export function recordWasmFrameProfile( + entries: Array<{ name: string; durationMs: number }>, +): void { + if (!isRenderPerfEnabled()) return; + for (const entry of entries) { + recordSpan({ name: entry.name, durationMs: entry.durationMs }); + } +} + +/** + * Called once per frame by the top of the render pipeline. Rolls the + * pending-frame counters into the aggregate and triggers a flush on cadence. + */ +export function onRenderPerfFrameComplete(): void { + if (!isRenderPerfEnabled()) return; + for (const [name, count] of pendingCountersThisFrame) { + let stats = counters.get(name); + if (!stats) { + stats = { total: 0, frames: 0 }; + counters.set(name, stats); + } + stats.total += count; + stats.frames += 1; + } + pendingCountersThisFrame.clear(); + + framesSinceFlush += 1; + if (framesSinceFlush >= FLUSH_EVERY) { + flush(); + } +} + +function flush(): void { + const spanRows: Array> = []; + for (const [name, stats] of spans) { + if (stats.samples.length === 0) continue; + const sorted = [...stats.samples].sort((a, b) => a - b); + const p = (q: number) => + sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))]; + const sum = sorted.reduce((acc, v) => acc + v, 0); + spanRows.push({ + span: name, + count: sorted.length, + meanMs: +(sum / sorted.length).toFixed(2), + p50Ms: +p(0.5).toFixed(2), + p95Ms: +p(0.95).toFixed(2), + maxMs: +sorted[sorted.length - 1].toFixed(2), + }); + } + spanRows.sort((a, b) => Number(b.meanMs) - Number(a.meanMs)); + + const counterRows: Array> = []; + for (const [name, stats] of counters) { + counterRows.push({ + counter: name, + perFrame: +(stats.total / Math.max(1, stats.frames)).toFixed(2), + total: stats.total, + frames: stats.frames, + }); + } + counterRows.sort((a, b) => Number(b.perFrame) - Number(a.perFrame)); + + console.groupCollapsed( + `[render-perf] summary over ${framesSinceFlush} frames`, + ); + if (spanRows.length > 0) console.table(spanRows); + if (counterRows.length > 0) console.table(counterRows); + console.groupEnd(); + + spans.clear(); + counters.clear(); + framesSinceFlush = 0; +} diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx index d3cb2a49..e8f5c572 100644 --- a/apps/web/src/preview/components/index.tsx +++ b/apps/web/src/preview/components/index.tsx @@ -132,7 +132,7 @@ function PreviewCanvas({ isVisible: boolean; }) => void; }) { - const canvasRef = useRef(null); + const canvasMountRef = useRef(null); const viewportRef = useRef(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef(null); @@ -158,35 +158,51 @@ function PreviewCanvas({ }); }, [nativeWidth, nativeHeight, activeProject.settings.fps]); - const render = useCallback(() => { - if (canvasRef.current && renderTree && !renderingRef.current) { - 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 || - renderTree !== lastSceneRef.current - ) { - renderingRef.current = true; - lastSceneRef.current = renderTree; - lastFrameRef.current = frame; - renderer - .renderToCanvas({ - node: renderTree, - time: renderTime, - targetCanvas: canvasRef.current, - }) - .then(() => { - renderingRef.current = false; - }); + // Mount the compositor's output canvas directly into the preview. wgpu + // renders straight into this element, so there is no intermediate copy — + // the container div owns positioning/styling, the canvas itself fills it. + useEffect(() => { + const mount = canvasMountRef.current; + if (!mount) return; + const outputCanvas = renderer.getOutputCanvas(); + outputCanvas.style.display = "block"; + outputCanvas.style.width = "100%"; + outputCanvas.style.height = "100%"; + mount.appendChild(outputCanvas); + return () => { + if (outputCanvas.parentElement === mount) { + mount.removeChild(outputCanvas); } + }; + }, [renderer]); + + const render = useCallback(() => { + if (!renderTree || renderingRef.current) return; + + 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 && + renderTree === lastSceneRef.current + ) { + return; } + + renderingRef.current = true; + lastSceneRef.current = renderTree; + lastFrameRef.current = frame; + renderer + .render({ node: renderTree, time: renderTime }) + .then(() => { + renderingRef.current = false; + }); }, [renderer, renderTree, editor.playback, editor.timeline]); useRafLoop(render); @@ -286,22 +302,20 @@ function PreviewCanvas({ ref={viewportRef} className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden" > - +
resolveRenderTree({ node, renderer: this, time }), + }); + const { frame, textures } = await measureSpanAsync({ + name: "buildFrame", + fn: () => buildFrameDescriptor({ node, renderer: this }), }); wasmCompositor.ensureInitialized({ width: this.width, height: this.height, }); - wasmCompositor.syncTextures(textures); - wasmCompositor.render(frame); + measureSpanSync({ + name: "syncTextures", + fn: () => wasmCompositor.syncTextures(textures), + }); + measureSpanSync({ + name: "renderFrame", + fn: () => wasmCompositor.render(frame), + }); } async renderToCanvas({ @@ -98,12 +112,17 @@ export class CanvasRenderer { throw new Error("Failed to get target canvas context"); } - ctx.drawImage( - wasmCompositor.getCanvas(), - 0, - 0, - targetCanvas.width, - targetCanvas.height, - ); + measureSpanSync({ + name: "drawImage", + fn: () => + ctx.drawImage( + wasmCompositor.getCanvas(), + 0, + 0, + targetCanvas.width, + targetCanvas.height, + ), + }); + onRenderPerfFrameComplete(); } } diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts index 666bea03..190a0237 100644 --- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts +++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts @@ -1,5 +1,6 @@ import { drawCssBackground } from "@/gradients"; import { masksRegistry } from "@/masks"; +import { incrementCounter } from "@/diagnostics/render-perf"; import type { AnyBaseNode } from "../nodes/base-node"; import type { CanvasRenderer } from "../canvas-renderer"; import { createOffscreenCanvas } from "../canvas-utils"; @@ -21,16 +22,11 @@ import type { FrameItemDescriptor, LayerMaskDescriptor, QuadTransformDescriptor, + TextureCanvasDrawFn, + TextureUploadDescriptor, } from "./types"; import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; -export type TextureUploadDescriptor = { - id: string; - source: CanvasImageSource; - width: number; - height: number; -}; - export async function buildFrameDescriptor({ node, renderer, @@ -52,6 +48,9 @@ export async function buildFrameDescriptor({ textures, }); + incrementCounter({ name: "frameItems", by: items.length }); + incrementCounter({ name: "frameTextures", by: textures.size }); + return { frame: { width: renderer.width, @@ -93,31 +92,21 @@ async function collectNode({ if (node instanceof ColorNode) { const textureId = `${path}:color`; - const canvas = createOffscreenCanvas({ - width: renderer.width, - height: renderer.height, - }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) return; - if (/gradient\(/i.test(node.params.color)) { - drawCssBackground({ - ctx, - width: renderer.width, - height: renderer.height, - css: node.params.color, - }); - } else { - ctx.fillStyle = node.params.color; - ctx.fillRect(0, 0, renderer.width, renderer.height); - } + const { width, height } = renderer; textures.set(textureId, { + kind: "rendered", id: textureId, - source: canvas, - width: renderer.width, - height: renderer.height, + contentHash: `color:${node.params.color}:${width}x${height}`, + width, + height, + draw: (ctx) => { + if (/gradient\(/i.test(node.params.color)) { + drawCssBackground({ ctx, width, height, css: node.params.color }); + } else { + ctx.fillStyle = node.params.color; + ctx.fillRect(0, 0, width, height); + } + }, }); items.push({ type: "layer", @@ -147,36 +136,35 @@ async function collectNode({ return; } const textureId = `${path}:blur-background`; - const backdropCanvas = createOffscreenCanvas({ - width: renderer.width, - height: renderer.height, - }); - const backdropCtx = backdropCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!backdropCtx) return; + const { width, height } = renderer; const { backdropSource, passes } = node.resolved; - const coverScale = Math.max( - renderer.width / backdropSource.width, - renderer.height / backdropSource.height, - ); - const scaledWidth = backdropSource.width * coverScale; - const scaledHeight = backdropSource.height * coverScale; - const offsetX = (renderer.width - scaledWidth) / 2; - const offsetY = (renderer.height - scaledHeight) / 2; - backdropCtx.drawImage( - backdropSource.source, - offsetX, - offsetY, - scaledWidth, - scaledHeight, - ); + // Backdrop pixels come from a decoded video/image frame whose identity + // already changes when it changes. Hashing the source reference is + // enough to let us skip redraws on frozen frames. + const contentHash = `blur:${identityKey(backdropSource.source)}:${backdropSource.width}x${backdropSource.height}:${width}x${height}`; textures.set(textureId, { + kind: "rendered", id: textureId, - source: backdropCanvas, - width: renderer.width, - height: renderer.height, + contentHash, + width, + height, + draw: (ctx) => { + const coverScale = Math.max( + width / backdropSource.width, + height / backdropSource.height, + ); + const scaledWidth = backdropSource.width * coverScale; + const scaledHeight = backdropSource.height * coverScale; + const offsetX = (width - scaledWidth) / 2; + const offsetY = (height - scaledHeight) / 2; + ctx.drawImage( + backdropSource.source, + offsetX, + offsetY, + scaledWidth, + scaledHeight, + ); + }, }); items.push({ type: "layer", @@ -253,6 +241,7 @@ async function collectVisualSourceNode({ const textureId = `${path}:source`; textures.set(textureId, { + kind: "external", id: textureId, source, width: sourceWidth, @@ -305,28 +294,24 @@ function collectTextNode({ } const textureId = `${path}:text`; - const canvas = createOffscreenCanvas({ - width: renderer.width, - height: renderer.height, - }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - return; - } - - renderTextToContext({ - node, - ctx, - }); - + const { width, height } = renderer; + // Text output is fully determined by node.params + node.resolved. Both are + // plain data we can stringify cheaply; the resolved measured layout is the + // expensive part of text setup, so stringifying it here is orders of + // magnitude cheaper than re-rasterizing when nothing changed. + const contentHash = `text:${width}x${height}:${JSON.stringify({ + params: node.params, + resolved: node.resolved, + })}`; textures.set(textureId, { + kind: "rendered", id: textureId, - source: canvas, - width: renderer.width, - height: renderer.height, + contentHash, + width, + height, + draw: (ctx) => { + renderTextToContext({ node, ctx }); + }, }); items.push({ type: "layer", @@ -411,20 +396,6 @@ function buildMaskArtifacts({ return { mask: null, strokeLayer: null }; } - const elementMaskCanvas = createOffscreenCanvas({ - width: Math.round(transform.width), - height: Math.round(transform.height), - }); - const elementMaskCtx = elementMaskCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!elementMaskCtx) { - return { mask: null, strokeLayer: null }; - } - elementMaskCtx.clearRect(0, 0, transform.width, transform.height); - - let strokePath: Path2D | null = null; let feather = mask.params.feather; const canRenderMaskDirectly = Boolean(definition.renderer.renderMask); const shouldRenderMaskDirectly = @@ -432,81 +403,78 @@ function buildMaskArtifacts({ (!definition.renderer.buildPath || (mask.params.feather > 0 && definition.renderer.renderMaskHandlesFeather)); - if (shouldRenderMaskDirectly && definition.renderer.renderMask) { - definition.renderer.renderMask({ - resolvedParams: mask.params, - ctx: elementMaskCtx, - width: Math.round(transform.width), - height: Math.round(transform.height), - feather: mask.params.feather, - }); - if (definition.renderer.renderMaskHandlesFeather) { - feather = 0; - } - strokePath = - definition.renderer.buildStrokePath?.({ - resolvedParams: mask.params, - width: transform.width, - height: transform.height, - }) ?? null; - } else { - if (!definition.renderer.buildPath) { - return { mask: null, strokeLayer: null }; - } - const path2d = definition.renderer.buildPath({ - resolvedParams: mask.params, - width: transform.width, - height: transform.height, - }); - elementMaskCtx.fillStyle = "white"; - elementMaskCtx.fill(path2d); - strokePath = - definition.renderer.buildStrokePath?.({ - resolvedParams: mask.params, - width: transform.width, - height: transform.height, - }) ?? path2d; + if ( + shouldRenderMaskDirectly && + definition.renderer.renderMaskHandlesFeather + ) { + feather = 0; } - const fullMaskCanvas = createOffscreenCanvas({ - width: renderer.width, - height: renderer.height, - }); - const fullMaskCtx = fullMaskCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!fullMaskCtx) { - return { mask: null, strokeLayer: null }; - } - drawTransformedCanvas({ - ctx: fullMaskCtx, - source: elementMaskCanvas, - transform, - }); - const maskTextureId = `${path}:mask`; - textures.set(maskTextureId, { - id: maskTextureId, - source: fullMaskCanvas, - width: renderer.width, - height: renderer.height, - }); - - let strokeLayer: FrameItemDescriptor | null = null; - if ( - mask.params.strokeWidth > 0 && - (strokePath || definition.renderer.renderStroke) - ) { - const strokeCanvas = createOffscreenCanvas({ + const { width: canvasWidth, height: canvasHeight } = renderer; + const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`; + const drawMask: TextureCanvasDrawFn = (ctx) => { + const elementMaskCanvas = createOffscreenCanvas({ width: Math.round(transform.width), height: Math.round(transform.height), }); - const strokeCtx = strokeCanvas.getContext("2d") as + const elementMaskCtx = elementMaskCanvas.getContext("2d") as | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null; - if (strokeCtx) { + if (!elementMaskCtx) return; + + if (shouldRenderMaskDirectly && definition.renderer.renderMask) { + definition.renderer.renderMask({ + resolvedParams: mask.params, + ctx: elementMaskCtx, + width: Math.round(transform.width), + height: Math.round(transform.height), + feather: mask.params.feather, + }); + } else if (definition.renderer.buildPath) { + const path2d = definition.renderer.buildPath({ + resolvedParams: mask.params, + width: transform.width, + height: transform.height, + }); + elementMaskCtx.fillStyle = "white"; + elementMaskCtx.fill(path2d); + } else { + return; + } + + drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform }); + }; + textures.set(maskTextureId, { + kind: "rendered", + id: maskTextureId, + contentHash: maskContentHash, + width: canvasWidth, + height: canvasHeight, + draw: drawMask, + }); + + const hasStroke = + mask.params.strokeWidth > 0 && + (definition.renderer.renderStroke || + definition.renderer.buildStrokePath || + definition.renderer.buildPath); + let strokeLayer: FrameItemDescriptor | null = null; + if (hasStroke) { + const strokeTextureId = `${path}:mask-stroke`; + const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`; + const drawStroke: TextureCanvasDrawFn = (ctx) => { + const strokeCanvas = createOffscreenCanvas({ + width: Math.round(transform.width), + height: Math.round(transform.height), + }); + const strokeCtx = strokeCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!strokeCtx) return; + if (definition.renderer.renderStroke) { definition.renderer.renderStroke({ resolvedParams: mask.params, @@ -514,44 +482,44 @@ function buildMaskArtifacts({ width: transform.width, height: transform.height, }); - } else if (strokePath) { + } else { + const strokePath = + definition.renderer.buildStrokePath?.({ + resolvedParams: mask.params, + width: transform.width, + height: transform.height, + }) ?? + definition.renderer.buildPath?.({ + resolvedParams: mask.params, + width: transform.width, + height: transform.height, + }) ?? + null; + if (!strokePath) return; strokeCtx.strokeStyle = mask.params.strokeColor; strokeCtx.lineWidth = mask.params.strokeWidth; strokeCtx.stroke(strokePath); } - const fullStrokeCanvas = createOffscreenCanvas({ - width: renderer.width, - height: renderer.height, - }); - const fullStrokeCtx = fullStrokeCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (fullStrokeCtx) { - drawTransformedCanvas({ - ctx: fullStrokeCtx, - source: strokeCanvas, - transform, - }); - const strokeTextureId = `${path}:mask-stroke`; - textures.set(strokeTextureId, { - id: strokeTextureId, - source: fullStrokeCanvas, - width: renderer.width, - height: renderer.height, - }); - strokeLayer = { - type: "layer", - textureId: strokeTextureId, - transform: fullCanvasTransform(renderer), - opacity: 1, - blendMode: "normal", - effectPassGroups: [], - mask: null, - }; - } - } + drawTransformedCanvas({ ctx, source: strokeCanvas, transform }); + }; + textures.set(strokeTextureId, { + kind: "rendered", + id: strokeTextureId, + contentHash: strokeContentHash, + width: canvasWidth, + height: canvasHeight, + draw: drawStroke, + }); + strokeLayer = { + type: "layer", + textureId: strokeTextureId, + transform: fullCanvasTransform(renderer), + opacity: 1, + blendMode: "normal", + effectPassGroups: [], + mask: null, + }; } return { @@ -590,3 +558,23 @@ function drawTransformedCanvas({ ctx.drawImage(source, x, y, transform.width, transform.height); ctx.restore(); } + +function transformHash(transform: QuadTransformDescriptor): string { + return `${transform.centerX}:${transform.centerY}:${transform.width}:${transform.height}:${transform.rotationDegrees}:${transform.flipX ? 1 : 0}:${transform.flipY ? 1 : 0}`; +} + +// Stable identity key for CanvasImageSource. Using a WeakMap → counter keeps +// hash string length bounded and avoids holding sources alive. +const identityKeys = new WeakMap(); +let nextIdentity = 1; +function identityKey(source: CanvasImageSource): string { + if (typeof source === "object" && source !== null) { + let key = identityKeys.get(source); + if (key === undefined) { + key = nextIdentity++; + identityKeys.set(source, key); + } + return `@${key}`; + } + return "@?"; +} diff --git a/apps/web/src/services/renderer/compositor/types.ts b/apps/web/src/services/renderer/compositor/types.ts index af5a58c2..1a509909 100644 --- a/apps/web/src/services/renderer/compositor/types.ts +++ b/apps/web/src/services/renderer/compositor/types.ts @@ -40,3 +40,39 @@ export type LayerMaskDescriptor = { feather: number; inverted: boolean; }; + +export type TextureCanvasDrawFn = ( + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, +) => void; + +/** + * A layer texture whose pixels come from somewhere outside the renderer — + * typically a decoded video/image frame or a sticker. Cached by reference + * identity of the source object. + */ +export type ExternalTextureDescriptor = { + kind: "external"; + id: string; + source: CanvasImageSource; + width: number; + height: number; +}; + +/** + * A layer texture that the renderer rasterizes from scene state (color fill, + * text layout, mask shape, blur backdrop). Cached by `contentHash`: when it + * matches the previous frame's hash for this id, the upload is skipped + * entirely and the persistent canvas is not even cleared. + */ +export type RenderedTextureDescriptor = { + kind: "rendered"; + id: string; + contentHash: string; + width: number; + height: number; + draw: TextureCanvasDrawFn; +}; + +export type TextureUploadDescriptor = + | ExternalTextureDescriptor + | RenderedTextureDescriptor; diff --git a/apps/web/src/services/renderer/compositor/wasm-compositor.ts b/apps/web/src/services/renderer/compositor/wasm-compositor.ts index 16b03853..10684f11 100644 --- a/apps/web/src/services/renderer/compositor/wasm-compositor.ts +++ b/apps/web/src/services/renderer/compositor/wasm-compositor.ts @@ -1,12 +1,201 @@ import { getCompositorCanvas, + getLastFrameProfile, initCompositor, releaseTexture, renderFrame, resizeCompositor, uploadTexture, } from "opencut-wasm"; -import type { FrameDescriptor } from "./types"; +import { + incrementCounter, + isRenderPerfEnabled, + recordWasmFrameProfile, +} from "@/diagnostics/render-perf"; +import type { + ExternalTextureDescriptor, + FrameDescriptor, + RenderedTextureDescriptor, + TextureUploadDescriptor, +} from "./types"; + +/** + * One slot in the derived-texture cache. The OffscreenCanvas is persistent — + * we reuse and redraw into it across frames, which is the change that takes + * the WebGL upload path off the per-frame critical path for static content. + */ +type RenderedCacheEntry = { + kind: "rendered"; + canvas: OffscreenCanvas; + contentHash: string; + width: number; + height: number; +}; + +type ExternalCacheEntry = { + kind: "external"; + source: CanvasImageSource; + width: number; + height: number; +}; + +class WasmCompositor { + private canvas: HTMLCanvasElement | null = null; + private initializedSize: { width: number; height: number } | null = null; + private cache = new Map(); + + ensureInitialized({ width, height }: { width: number; height: number }) { + if (!this.canvas) { + initCompositor(width, height); + this.canvas = getCompositorCanvas(); + this.initializedSize = { width, height }; + return; + } + + if ( + !this.initializedSize || + this.initializedSize.width !== width || + this.initializedSize.height !== height + ) { + resizeCompositor(width, height); + this.initializedSize = { width, height }; + } + } + + getCanvas(): HTMLCanvasElement { + if (!this.canvas) { + throw new Error("Compositor is not initialized"); + } + return this.canvas; + } + + syncTextures(textures: TextureUploadDescriptor[]) { + const nextIds = new Set(textures.map((texture) => texture.id)); + for (const previousId of this.cache.keys()) { + if (!nextIds.has(previousId)) { + releaseTexture(previousId); + this.cache.delete(previousId); + } + } + + for (const texture of textures) { + if (texture.kind === "external") { + this.syncExternalTexture(texture); + } else { + this.syncRenderedTexture(texture); + } + } + } + + render(frame: FrameDescriptor) { + renderFrame(frame); + if (isRenderPerfEnabled()) { + recordWasmFrameProfile( + getLastFrameProfile() as Array<{ name: string; durationMs: number }>, + ); + } + } + + private syncExternalTexture(texture: ExternalTextureDescriptor) { + const previous = this.cache.get(texture.id); + if ( + previous?.kind === "external" && + previous.source === texture.source && + previous.width === texture.width && + previous.height === texture.height + ) { + incrementCounter({ name: "textureCacheHit" }); + return; + } + + incrementCounter({ name: "textureUpload" }); + incrementCounter({ + name: "textureUploadPixels", + by: texture.width * texture.height, + }); + uploadTexture({ + id: texture.id, + source: ensureOffscreenCanvas({ + source: texture.source, + width: texture.width, + height: texture.height, + label: `texture upload ${texture.id}`, + }), + width: texture.width, + height: texture.height, + }); + this.cache.set(texture.id, { + kind: "external", + source: texture.source, + width: texture.width, + height: texture.height, + }); + } + + private syncRenderedTexture(texture: RenderedTextureDescriptor) { + const previous = this.cache.get(texture.id); + if ( + previous?.kind === "rendered" && + previous.contentHash === texture.contentHash && + previous.width === texture.width && + previous.height === texture.height + ) { + incrementCounter({ name: "textureCacheHit" }); + return; + } + + const canvas = + previous?.kind === "rendered" && + previous.width === texture.width && + previous.height === texture.height + ? previous.canvas + : createBackingCanvas({ + width: texture.width, + height: texture.height, + }); + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error(`Failed to get 2d context for texture ${texture.id}`); + } + ctx.clearRect(0, 0, texture.width, texture.height); + texture.draw(ctx); + + incrementCounter({ name: "textureUpload" }); + incrementCounter({ + name: "textureUploadPixels", + by: texture.width * texture.height, + }); + uploadTexture({ + id: texture.id, + source: canvas, + width: texture.width, + height: texture.height, + }); + this.cache.set(texture.id, { + kind: "rendered", + canvas, + contentHash: texture.contentHash, + width: texture.width, + height: texture.height, + }); + } +} + +export const wasmCompositor = new WasmCompositor(); + +function createBackingCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas { + if (typeof OffscreenCanvas === "undefined") { + throw new Error("OffscreenCanvas is not supported in this environment"); + } + return new OffscreenCanvas(width, height); +} function ensureOffscreenCanvas({ source, @@ -36,92 +225,3 @@ function ensureOffscreenCanvas({ context.drawImage(source, 0, 0, width, height); return canvas; } - -export type TextureUploadDescriptor = { - id: string; - source: CanvasImageSource; - width: number; - height: number; -}; - -class WasmCompositor { - private canvas: HTMLCanvasElement | null = null; - private initializedSize: { width: number; height: number } | null = null; - private retainedTextureIds = new Set(); - private uploadedTextures = new Map< - string, - { source: CanvasImageSource; width: number; height: number } - >(); - - ensureInitialized({ width, height }: { width: number; height: number }) { - if (!this.canvas) { - initCompositor(width, height); - this.canvas = getCompositorCanvas(); - this.initializedSize = { width, height }; - return; - } - - if ( - !this.initializedSize || - this.initializedSize.width !== width || - this.initializedSize.height !== height - ) { - resizeCompositor(width, height); - this.initializedSize = { width, height }; - } - } - - getCanvas(): HTMLCanvasElement { - if (!this.canvas) { - throw new Error("Compositor is not initialized"); - } - return this.canvas; - } - - syncTextures(textures: TextureUploadDescriptor[]) { - const nextIds = new Set(textures.map((texture) => texture.id)); - for (const previousId of this.retainedTextureIds) { - if (!nextIds.has(previousId)) { - releaseTexture(previousId); - this.uploadedTextures.delete(previousId); - } - } - - for (const texture of textures) { - const previousTexture = this.uploadedTextures.get(texture.id); - if ( - previousTexture?.source === texture.source && - previousTexture.width === texture.width && - previousTexture.height === texture.height - ) { - continue; - } - - const sourceCanvas = ensureOffscreenCanvas({ - source: texture.source, - width: texture.width, - height: texture.height, - label: `texture upload ${texture.id}`, - }); - uploadTexture({ - id: texture.id, - source: sourceCanvas, - width: texture.width, - height: texture.height, - }); - this.uploadedTextures.set(texture.id, { - source: texture.source, - width: texture.width, - height: texture.height, - }); - } - - this.retainedTextureIds = nextIds; - } - - render(frame: FrameDescriptor) { - renderFrame(frame); - } -} - -export const wasmCompositor = new WasmCompositor(); diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs index e38eff8c..0fcb2e8c 100644 --- a/rust/crates/gpu/src/context.rs +++ b/rust/crates/gpu/src/context.rs @@ -353,6 +353,14 @@ impl GpuContext { self.supports_external_texture_copies } + /// The HTML canvas that owns the backing WebGL context, if running on the + /// WebGL fallback. Callers on that path can mount this canvas directly + /// instead of copying pixels out of it every frame. + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + pub fn gl_canvas(&self) -> Option<&web_sys::HtmlCanvasElement> { + self.gl_canvas.as_ref() + } + pub fn render_texture_to_surface( &self, texture: &wgpu::Texture, @@ -571,7 +579,7 @@ impl GpuContext { } #[cfg(all(feature = "wasm", target_arch = "wasm32"))] - fn render_texture_to_gl_canvas_surface( + pub fn render_texture_to_gl_canvas_surface( &self, texture: &wgpu::Texture, width: u32, diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml index f12a8cbc..3fbfe5ba 100644 --- a/rust/wasm/Cargo.toml +++ b/rust/wasm/Cargo.toml @@ -24,7 +24,7 @@ serde-wasm-bindgen = "0.6.5" time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] } wasm-bindgen = "0.2.116" wasm-bindgen-futures = "0.4.66" -web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] } +web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window", "Performance"] } [features] default = ["wasm"] diff --git a/rust/wasm/src/compositor.rs b/rust/wasm/src/compositor.rs index 126c7468..84467842 100644 --- a/rust/wasm/src/compositor.rs +++ b/rust/wasm/src/compositor.rs @@ -11,6 +11,7 @@ use crate::gpu::{ import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property, with_gpu_runtime, }; +use crate::perf; struct CompositorRuntime { canvas: web_sys::HtmlCanvasElement, @@ -24,13 +25,21 @@ thread_local! { #[wasm_bindgen(js_name = initCompositor)] pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> { with_gpu_runtime(|gpu_runtime| { - let document = web_sys::window() - .and_then(|window| window.document()) - .ok_or_else(|| JsValue::from_str("Document is not available"))?; - let canvas: web_sys::HtmlCanvasElement = document - .create_element("canvas")? - .dyn_into() - .map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?; + // On WebGL, wgpu is bound to a specific canvas; reuse it so the UI + // can mount the output directly instead of copying pixels through + // an intermediate 2D canvas every frame. On WebGPU, surface rendering + // works against any canvas so we create a fresh one. + let canvas = if let Some(gl_canvas) = gpu_runtime.context.gl_canvas() { + gl_canvas.clone() + } else { + let document = web_sys::window() + .and_then(|window| window.document()) + .ok_or_else(|| JsValue::from_str("Document is not available"))?; + document + .create_element("canvas")? + .dyn_into::() + .map_err(|_| JsValue::from_str("Failed to create compositor canvas"))? + }; canvas.set_width(width); canvas.set_height(height); @@ -119,8 +128,12 @@ pub fn release_texture(id: String) -> Result<(), JsValue> { #[wasm_bindgen(js_name = renderFrame)] pub fn render_frame(options: JsValue) -> Result<(), JsValue> { + perf::reset(); + + let t_deserialize = perf::now_ms(); let frame: FrameDescriptor = serde_wasm_bindgen::from_value(options) .map_err(|error| JsValue::from_str(&format!("Invalid frame descriptor: {error}")))?; + perf::record("wasm.deserialize", perf::now_ms() - t_deserialize); with_gpu_runtime(|gpu_runtime| { COMPOSITOR_RUNTIME.with(|runtime| { @@ -132,13 +145,16 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> { }; if gpu_runtime.context.supports_surface_rendering() { + let t_surface = perf::now_ms(); let surface = gpu_runtime .context .instance() .create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone())) .map_err(|error| JsValue::from_str(&error.to_string()))?; + perf::record("wasm.surfaceCreate", perf::now_ms() - t_surface); - runtime + let t_render = perf::now_ms(); + let result = runtime .compositor .render_frame( &gpu_runtime.context, @@ -147,24 +163,29 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> { surface: &surface, }, ) - .map_err(|error| JsValue::from_str(&error.to_string())) + .map_err(|error| JsValue::from_str(&error.to_string())); + perf::record("wasm.renderFrameToSurface", perf::now_ms() - t_render); + result } else { - // WebGL cannot surface-render to an arbitrary canvas element. - // Composite to a texture, then blit to the canvas via the 2D context. + // WebGL surface-renders to the canvas its GL context was created + // on; `init_compositor` already pointed `runtime.canvas` at that + // same canvas, so presenting writes directly to the canvas the + // UI mounts. No intermediate copy. + let t_composite = perf::now_ms(); let texture = runtime .compositor .render_frame_to_texture(&gpu_runtime.context, &frame) .map_err(|error| JsValue::from_str(&error.to_string()))?; + perf::record("wasm.compositeToTexture", perf::now_ms() - t_composite); + let t_present = perf::now_ms(); gpu_runtime .context - .render_texture_via_gl_canvas( - &texture, - &runtime.canvas, - frame.width, - frame.height, - ) - .map_err(|error| JsValue::from_str(&error.to_string())) + .render_texture_to_gl_canvas_surface(&texture, frame.width, frame.height) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + perf::record("wasm.presentToGlCanvas", perf::now_ms() - t_present); + + Ok(()) } }) }) diff --git a/rust/wasm/src/perf.rs b/rust/wasm/src/perf.rs new file mode 100644 index 00000000..0c346a4d --- /dev/null +++ b/rust/wasm/src/perf.rs @@ -0,0 +1,51 @@ +#![cfg(target_arch = "wasm32")] + +//! Per-frame profile buffer for the render pipeline. +//! +//! Sub-span timings are recorded into a thread-local during `renderFrame` +//! and drained by JS via `getLastFrameProfile()`. + +use std::cell::RefCell; + +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; + +thread_local! { + static LAST_FRAME_PROFILE: RefCell> = const { RefCell::new(Vec::new()) }; +} + +pub(crate) fn now_ms() -> f64 { + web_sys::window() + .and_then(|window| window.performance()) + .map(|performance| performance.now()) + .unwrap_or(0.0) +} + +pub(crate) fn reset() { + LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().clear()); +} + +pub(crate) fn record(name: &'static str, duration_ms: f64) { + LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().push((name, duration_ms))); +} + +#[wasm_bindgen(js_name = getLastFrameProfile)] +pub fn get_last_frame_profile() -> Array { + LAST_FRAME_PROFILE.with(|cell| { + let entries = cell.borrow(); + let array = Array::new_with_length(entries.len() as u32); + for (index, (name, duration_ms)) in entries.iter().enumerate() { + let entry = Object::new(); + Reflect::set(&entry, &JsValue::from_str("name"), &JsValue::from_str(name)) + .expect("set name"); + Reflect::set( + &entry, + &JsValue::from_str("durationMs"), + &JsValue::from_f64(*duration_ms), + ) + .expect("set durationMs"); + array.set(index as u32, entry.into()); + } + array + }) +} diff --git a/rust/wasm/src/wasm.rs b/rust/wasm/src/wasm.rs index 1a33092a..0687ff4b 100644 --- a/rust/wasm/src/wasm.rs +++ b/rust/wasm/src/wasm.rs @@ -6,6 +6,8 @@ mod effects; mod gpu; #[cfg(target_arch = "wasm32")] mod masks; +#[cfg(target_arch = "wasm32")] +mod perf; #[cfg(target_arch = "wasm32")] pub use compositor::*; @@ -15,4 +17,6 @@ pub use effects::*; pub use gpu::*; #[cfg(target_arch = "wasm32")] pub use masks::*; +#[cfg(target_arch = "wasm32")] +pub use perf::*; pub use time::*; From 0dffefdd59d8c70fece63436e8556f8ec1e43a49 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 25 Apr 2026 02:46:31 +0200 Subject: [PATCH 02/29] fix: surface unsupported video codec on import, fix Input leaks --- apps/web/src/media/mediabunny.ts | 86 +++++++--- apps/web/src/media/processing.ts | 155 +++++-------------- apps/web/src/media/thumbnail.ts | 56 +++++++ apps/web/src/services/video-cache/service.ts | 14 +- 4 files changed, 166 insertions(+), 145 deletions(-) create mode 100644 apps/web/src/media/thumbnail.ts diff --git a/apps/web/src/media/mediabunny.ts b/apps/web/src/media/mediabunny.ts index 47ffaeb1..3c949b35 100644 --- a/apps/web/src/media/mediabunny.ts +++ b/apps/web/src/media/mediabunny.ts @@ -1,43 +1,81 @@ -import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; +import { + Input, + ALL_FORMATS, + BlobSource, + VideoSampleSink, + type VideoCodec, +} from "mediabunny"; import { createTimelineAudioBuffer } from "@/media/audio"; import type { SceneTracks } from "@/timeline"; import type { MediaAsset } from "@/media/types"; import { TICKS_PER_SECOND } from "@/wasm"; +import { renderThumbnailDataUrl } from "./thumbnail"; -export async function getVideoInfo({ - videoFile, -}: { - videoFile: File; -}): Promise<{ +export type VideoFileData = { duration: number; width: number; height: number; fps: number; hasAudio: boolean; -}> { + codec: VideoCodec | null; + canDecode: boolean; + thumbnailUrl: string | null; +}; + +export async function readVideoFile({ + file, +}: { + file: File; +}): Promise { const input = new Input({ - source: new BlobSource(videoFile), + source: new BlobSource(file), formats: ALL_FORMATS, }); - const duration = await input.computeDuration(); - const videoTrack = await input.getPrimaryVideoTrack(); + try { + const duration = await input.computeDuration(); + const videoTrack = await input.getPrimaryVideoTrack(); - if (!videoTrack) { - throw new Error("No video track found in the file"); + if (!videoTrack) { + throw new Error("No video track found in the file"); + } + + const canDecode = await videoTrack.canDecode(); + const packetStats = await videoTrack.computePacketStats(100); + const audioTrack = await input.getPrimaryAudioTrack(); + + let thumbnailUrl: string | null = null; + if (canDecode) { + const sink = new VideoSampleSink(videoTrack); + const frame = await sink.getSample(1); + if (frame) { + try { + thumbnailUrl = renderThumbnailDataUrl({ + width: videoTrack.displayWidth, + height: videoTrack.displayHeight, + draw: ({ context, width, height }) => { + frame.draw(context, 0, 0, width, height); + }, + }); + } finally { + frame.close(); + } + } + } + + return { + duration, + width: videoTrack.displayWidth, + height: videoTrack.displayHeight, + fps: packetStats.averagePacketRate, + hasAudio: audioTrack !== null, + codec: videoTrack.codec, + canDecode, + thumbnailUrl, + }; + } finally { + input.dispose(); } - - const packetStats = await videoTrack.computePacketStats(100); - const fps = packetStats.averagePacketRate; - const audioTrack = await input.getPrimaryAudioTrack(); - - return { - duration, - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - fps, - hasAudio: audioTrack !== null, - }; } const SAMPLE_RATE = 44100; diff --git a/apps/web/src/media/processing.ts b/apps/web/src/media/processing.ts index d8d483f8..34ccadbc 100644 --- a/apps/web/src/media/processing.ts +++ b/apps/web/src/media/processing.ts @@ -1,15 +1,25 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; import { toast } from "sonner"; import { getMediaTypeFromFile } from "@/media/media-utils"; import { formatStorageBytes } from "@/services/storage/quota"; import { storageService } from "@/services/storage/service"; import type { MediaAsset } from "@/media/types"; -import { getVideoInfo } from "./mediabunny"; +import { readVideoFile } from "./mediabunny"; +import type { VideoFileData } from "./mediabunny"; +import { renderThumbnailDataUrl } from "./thumbnail"; export interface ProcessedMediaAsset extends Omit {} -const THUMBNAIL_MAX_WIDTH = 1280; -const THUMBNAIL_MAX_HEIGHT = 720; +const getUnsupportedVideoDescription = ({ + codec, +}: { + codec: VideoFileData["codec"]; +}): string => { + const codecLabel = codec ? codec.toUpperCase() : "this video codec"; + + return codec === "hevc" + ? `${codecLabel} cannot be decoded in this browser, so this clip may not preview correctly. Convert it to H.264 MP4 or try importing it in Safari.` + : `${codecLabel} cannot be decoded in this browser, so this clip may not preview correctly. Convert it to H.264 MP4 and reimport it.`; +}; const getStorageLimitDescription = ({ fileSize, @@ -29,103 +39,6 @@ const getStorageLimitDescription = ({ })} is safely available in browser storage.`; }; -const getThumbnailSize = ({ - width, - height, -}: { - width: number; - height: number; -}): { width: number; height: number } => { - const aspectRatio = width / height; - let targetWidth = width; - let targetHeight = height; - - if (targetWidth > THUMBNAIL_MAX_WIDTH) { - targetWidth = THUMBNAIL_MAX_WIDTH; - targetHeight = Math.round(targetWidth / aspectRatio); - } - if (targetHeight > THUMBNAIL_MAX_HEIGHT) { - targetHeight = THUMBNAIL_MAX_HEIGHT; - targetWidth = Math.round(targetHeight * aspectRatio); - } - - return { width: targetWidth, height: targetHeight }; -}; - -const renderToThumbnailDataUrl = ({ - width, - height, - draw, -}: { - width: number; - height: number; - draw: ({ - context, - width, - height, - }: { - context: CanvasRenderingContext2D; - width: number; - height: number; - }) => void; -}): string => { - const size = getThumbnailSize({ width, height }); - const canvas = document.createElement("canvas"); - canvas.width = size.width; - canvas.height = size.height; - const context = canvas.getContext("2d"); - - if (!context) { - throw new Error("Could not get canvas context"); - } - - draw({ context, width: size.width, height: size.height }); - return canvas.toDataURL("image/jpeg", 0.8); -}; - -async function generateThumbnail({ - videoFile, - timeInSeconds, -}: { - videoFile: File; - timeInSeconds: number; -}): Promise { - const input = new Input({ - source: new BlobSource(videoFile), - formats: ALL_FORMATS, - }); - - const videoTrack = await input.getPrimaryVideoTrack(); - if (!videoTrack) { - throw new Error("No video track found in the file"); - } - - const canDecode = await videoTrack.canDecode(); - if (!canDecode) { - throw new Error("Video codec not supported for decoding"); - } - - const sink = new VideoSampleSink(videoTrack); - - const frame = await sink.getSample(timeInSeconds); - - if (!frame) { - throw new Error("Could not get frame at specified time"); - } - - try { - return renderToThumbnailDataUrl({ - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - draw: ({ context, width, height }) => { - frame.draw(context, 0, 0, width, height); - }, - }); - } finally { - frame.close(); - } -} - async function generateImageThumbnail({ imageFile, }: { @@ -137,7 +50,7 @@ async function generateImageThumbnail({ image.addEventListener("load", () => { try { - const thumbnailUrl = renderToThumbnailDataUrl({ + const thumbnailUrl = renderThumbnailDataUrl({ width: image.naturalWidth, height: image.naturalHeight, draw: ({ context, width, height }) => { @@ -220,24 +133,34 @@ export async function processMediaAssets({ height = result.height; } else if (fileType === "video") { try { - const videoInfo = await getVideoInfo({ videoFile: file }); - duration = videoInfo.duration; - width = videoInfo.width; - height = videoInfo.height; - fps = Number.isFinite(videoInfo.fps) - ? Math.round(videoInfo.fps) + const videoData = await readVideoFile({ file }); + duration = videoData.duration; + width = videoData.width; + height = videoData.height; + fps = Number.isFinite(videoData.fps) + ? Math.round(videoData.fps) : undefined; - hasAudio = videoInfo.hasAudio; + hasAudio = videoData.hasAudio; + thumbnailUrl = videoData.thumbnailUrl ?? undefined; - thumbnailUrl = await generateThumbnail({ - videoFile: file, - timeInSeconds: 1, - }); + if (!videoData.canDecode) { + toast.error(`Can't preview ${file.name}`, { + description: getUnsupportedVideoDescription({ + codec: videoData.codec, + }), + }); + } } catch (error) { - console.warn("Video processing failed", error); + const message = + error instanceof Error + ? error.message + : "Could not process video"; + + toast.error(`Couldn't process ${file.name}`, { + description: message, + }); } } else if (fileType === "audio") { - // For audio, we don't set width/height/fps (they'll be undefined) duration = await getMediaDuration({ file }); } @@ -264,7 +187,7 @@ export async function processMediaAssets({ } catch (error) { console.error("Error processing file:", file.name, error); toast.error(`Failed to process ${file.name}`); - URL.revokeObjectURL(url); // Clean up on error + URL.revokeObjectURL(url); } } diff --git a/apps/web/src/media/thumbnail.ts b/apps/web/src/media/thumbnail.ts new file mode 100644 index 00000000..e71077d9 --- /dev/null +++ b/apps/web/src/media/thumbnail.ts @@ -0,0 +1,56 @@ +const THUMBNAIL_MAX_WIDTH = 1280; +const THUMBNAIL_MAX_HEIGHT = 720; + +export function thumbnailSize({ + width, + height, +}: { + width: number; + height: number; +}): { width: number; height: number } { + const aspectRatio = width / height; + let targetWidth = width; + let targetHeight = height; + + if (targetWidth > THUMBNAIL_MAX_WIDTH) { + targetWidth = THUMBNAIL_MAX_WIDTH; + targetHeight = Math.round(targetWidth / aspectRatio); + } + if (targetHeight > THUMBNAIL_MAX_HEIGHT) { + targetHeight = THUMBNAIL_MAX_HEIGHT; + targetWidth = Math.round(targetHeight * aspectRatio); + } + + return { width: targetWidth, height: targetHeight }; +} + +export function renderThumbnailDataUrl({ + width, + height, + draw, +}: { + width: number; + height: number; + draw: ({ + context, + width, + height, + }: { + context: CanvasRenderingContext2D; + width: number; + height: number; + }) => void; +}): string { + const size = thumbnailSize({ width, height }); + const canvas = document.createElement("canvas"); + canvas.width = size.width; + canvas.height = size.height; + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("Could not get canvas context"); + } + + draw({ context, width: size.width, height: size.height }); + return canvas.toDataURL("image/jpeg", 0.8); +} diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts index e5f3991d..31467065 100644 --- a/apps/web/src/services/video-cache/service.ts +++ b/apps/web/src/services/video-cache/service.ts @@ -7,6 +7,7 @@ import { } from "mediabunny"; interface VideoSinkData { + input: Input; sink: CanvasSink; iterator: AsyncGenerator | null; currentFrame: WrappedCanvas | null; @@ -262,12 +263,12 @@ export class VideoCache { mediaId: string; file: File; }): Promise { - try { - const input = new Input({ - source: new BlobSource(file), - formats: ALL_FORMATS, - }); + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, + }); + try { const videoTrack = await input.getPrimaryVideoTrack(); if (!videoTrack) { throw new Error("No video track found"); @@ -284,6 +285,7 @@ export class VideoCache { }); this.sinks.set(mediaId, { + input, sink, iterator: null, currentFrame: null, @@ -293,6 +295,7 @@ export class VideoCache { prefetchPromise: null, }); } catch (error) { + input.dispose(); console.error(`Failed to initialize video sink for ${mediaId}:`, error); throw error; } @@ -305,6 +308,7 @@ export class VideoCache { void sinkData.iterator.return(); } + sinkData.input.dispose(); this.sinks.delete(mediaId); } From 6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 25 Apr 2026 03:56:22 +0200 Subject: [PATCH 03/29] refactor: centralize editor interaction state Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor --- apps/web/src/app/brand/page.tsx | 536 ++++++------ .../editor/panels/assets/draggable-item.tsx | 479 +++++------ apps/web/src/core/managers/audio-manager.ts | 23 +- .../web/src/core/managers/playback-manager.ts | 44 +- .../web/src/core/managers/timeline-manager.ts | 12 +- apps/web/src/media/use-file-upload.ts | 14 +- apps/web/src/preview/components/toolbar.tsx | 25 +- .../preview-interaction-controller.ts | 583 +++++++++++++ .../transform-handle-controller.ts | 753 +++++++++++++++++ .../preview/hooks/use-preview-interaction.ts | 499 ++--------- .../preview/hooks/use-transform-handles.ts | 643 ++------------ apps/web/src/services/video-cache/service.ts | 31 +- apps/web/src/timeline/components/index.tsx | 69 +- .../timeline/components/timeline-element.tsx | 23 +- .../timeline/components/timeline-track.tsx | 26 +- .../controllers/drag-drop-controller.ts | 577 +++++++++++++ .../element-interaction-controller.ts | 691 +++++++++++++++ .../controllers/keyframe-drag-controller.ts | 342 ++++++++ .../controllers/playhead-controller.ts | 314 +++++++ .../timeline/controllers/resize-controller.ts | 329 ++++++++ .../timeline/controllers/seek-controller.ts | 210 +++++ .../timeline/controllers/zoom-controller.ts | 285 +++++++ apps/web/src/timeline/creation.ts | 17 + apps/web/src/timeline/drag-data.ts | 48 -- apps/web/src/timeline/drag-source.ts | 40 + .../timeline/group-resize/compute-resize.ts | 58 +- apps/web/src/timeline/group-resize/types.ts | 4 +- .../hooks/element/use-element-interaction.ts | 785 ++---------------- .../hooks/element/use-keyframe-drag.ts | 338 +------- .../timeline/hooks/use-timeline-drag-drop.ts | 659 +-------------- .../timeline/hooks/use-timeline-playhead.ts | 329 ++------ .../src/timeline/hooks/use-timeline-resize.ts | 363 ++------ .../src/timeline/hooks/use-timeline-seek.ts | 195 +---- .../src/timeline/hooks/use-timeline-zoom.ts | 277 +----- apps/web/src/timeline/types.ts | 631 +++++++------- rust/crates/compositor/src/compositor.rs | 1 - rust/crates/gpu/src/context.rs | 139 ++-- rust/wasm/src/compositor.rs | 73 +- 38 files changed, 5692 insertions(+), 4773 deletions(-) create mode 100644 apps/web/src/preview/controllers/preview-interaction-controller.ts create mode 100644 apps/web/src/preview/controllers/transform-handle-controller.ts create mode 100644 apps/web/src/timeline/controllers/drag-drop-controller.ts create mode 100644 apps/web/src/timeline/controllers/element-interaction-controller.ts create mode 100644 apps/web/src/timeline/controllers/keyframe-drag-controller.ts create mode 100644 apps/web/src/timeline/controllers/playhead-controller.ts create mode 100644 apps/web/src/timeline/controllers/resize-controller.ts create mode 100644 apps/web/src/timeline/controllers/seek-controller.ts create mode 100644 apps/web/src/timeline/controllers/zoom-controller.ts delete mode 100644 apps/web/src/timeline/drag-data.ts create mode 100644 apps/web/src/timeline/drag-source.ts diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index a7085154..94e3f3e1 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -1,268 +1,268 @@ -"use client"; - -import type { CSSProperties } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { Check, Copy, Download } from "lucide-react"; -import { useState } from "react"; -import { BasePage } from "@/app/base-page"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; - -function downloadAsset(src: string) { - const filename = src.split("/").pop() ?? "asset.svg"; - const a = document.createElement("a"); - a.href = src; - a.download = filename; - a.click(); -} - -async function copyAsset(src: string) { - const res = await fetch(src); - const text = await res.text(); - await navigator.clipboard.writeText(text); -} - -const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); - -type AssetTheme = "dark" | "light" | "icon"; - -interface AssetVariant { - src: string; - theme: AssetTheme; - label: string; - width: number; - height: number; -} - -interface AssetSection { - title: string; - description: string; - cols: "1" | "2"; - assets: AssetVariant[]; -} - -const ASSET_SECTIONS: AssetSection[] = [ - { - title: "Symbol", - description: - "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", - cols: "2", - assets: [ - { - src: "/logos/opencut/symbol.svg", - theme: "dark", - label: "Symbol", - width: 400, - height: 400, - }, - { - src: "/logos/opencut/symbol-light.svg", - theme: "light", - label: "Symbol", - width: 400, - height: 400, - }, - ], - }, - { - title: "Lockup", - description: - "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", - cols: "2", - assets: [ - { - src: "/logos/opencut/logo.svg", - theme: "dark", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/logo-light.svg", - theme: "light", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/text.svg", - theme: "dark", - label: "Text", - width: 1760, - height: 400, - }, - { - src: "/logos/opencut/text-light.svg", - theme: "light", - label: "Text", - width: 1760, - height: 400, - }, - ], - }, -]; - -export default function BrandPage() { - return ( - - Download OpenCut brand assets for use in your projects.{" "} - - document - .getElementById("guidelines") - ?.scrollIntoView({ behavior: "smooth" }) - } - > - Read the brand guidelines. - - - } - action={ - - } - > -
- {ASSET_SECTIONS.map((section) => ( -
-
-

{section.title}

-

- {section.description} -

-
-
- {section.assets.map((variant) => ( - - ))} -
-
- ))} -
- - - -
-
-

Usage

-

- OpenCut is open source — the code is free to use under its license. - That license does not cover the name or logo. You can say you use - OpenCut, that your project integrates with OpenCut, or that it was - built on top of OpenCut. You cannot name your product OpenCut, imply - we made or endorse your product, or use the marks commercially - without asking first. For anything unclear, reach out at{" "} - - brand@opencut.app - - . -

-
- -
-

What's not allowed

-
    - {[ - "Using OpenCut in the name of your product, service, or domain.", - "Implying that OpenCut made, sponsors, or endorses your work.", - "Using the logo or name on merchandise or commercial marketing.", - "Modifying the marks.", - ].map((item) => ( -
  • - - - {item} -
  • - ))} -
-
-
-
- ); -} - -const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { - light: { - backgroundImage: - "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#000", - }, - dark: { - backgroundImage: - "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#f5f5f5", - }, -}; - -function AssetCard({ variant }: { variant: AssetVariant }) { - const [copied, setCopied] = useState(false); - - async function handleCopy() { - await copyAsset(variant.src); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - return ( - -
- {variant.label} -
- - -
- ); -} +"use client"; + +import type { CSSProperties } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Check, Copy, Download } from "lucide-react"; +import { useState } from "react"; +import { BasePage } from "@/app/base-page"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; + +function downloadAsset(src: string) { + const filename = src.split("/").pop() ?? "asset.svg"; + const a = document.createElement("a"); + a.href = src; + a.download = filename; + a.click(); +} + +async function copyAsset(src: string) { + const res = await fetch(src); + const text = await res.text(); + await navigator.clipboard.writeText(text); +} + +const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); + +type AssetTheme = "dark" | "light" | "icon"; + +interface AssetVariant { + src: string; + theme: AssetTheme; + label: string; + width: number; + height: number; +} + +interface AssetSection { + title: string; + description: string; + cols: "1" | "2"; + assets: AssetVariant[]; +} + +const ASSET_SECTIONS: AssetSection[] = [ + { + title: "Symbol", + description: + "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", + cols: "2", + assets: [ + { + src: "/logos/opencut/symbol.svg", + theme: "dark", + label: "Symbol", + width: 400, + height: 400, + }, + { + src: "/logos/opencut/symbol-light.svg", + theme: "light", + label: "Symbol", + width: 400, + height: 400, + }, + ], + }, + { + title: "Lockup", + description: + "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", + cols: "2", + assets: [ + { + src: "/logos/opencut/logo.svg", + theme: "dark", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/logo-light.svg", + theme: "light", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/text.svg", + theme: "dark", + label: "Text", + width: 1760, + height: 400, + }, + { + src: "/logos/opencut/text-light.svg", + theme: "light", + label: "Text", + width: 1760, + height: 400, + }, + ], + }, +]; + +export default function BrandPage() { + return ( + + Download OpenCut brand assets for use in your projects.{" "} + + document + .getElementById("guidelines") + ?.scrollIntoView({ behavior: "smooth" }) + } + > + Read the brand guidelines. + + + } + action={ + + } + > +
+ {ASSET_SECTIONS.map((section) => ( +
+
+

{section.title}

+

+ {section.description} +

+
+
+ {section.assets.map((variant) => ( + + ))} +
+
+ ))} +
+ + + +
+
+

Usage

+

+ OpenCut is open source — the code is free to use under its license. + That license does not cover the name or logo. You can say you use + OpenCut, that your project integrates with OpenCut, or that it was + built on top of OpenCut. You cannot name your product OpenCut, imply + we made or endorse your product, or use the marks commercially + without asking first. For anything unclear, reach out at{" "} + + brand@opencut.app + + . +

+
+ +
+

What's not allowed

+
    + {[ + "Using OpenCut in the name of your product, service, or domain.", + "Implying that OpenCut made, sponsors, or endorses your work.", + "Using the logo or name on merchandise or commercial marketing.", + "Modifying the marks.", + ].map((item) => ( +
  • + - + {item} +
  • + ))} +
+
+
+
+ ); +} + +const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { + light: { + backgroundImage: + "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#000", + }, + dark: { + backgroundImage: + "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#f5f5f5", + }, +}; + +function AssetCard({ variant }: { variant: AssetVariant }) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await copyAsset(variant.src); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + +
+ {variant.label} +
+ + +
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 2f8bc64a..7cf89fbe 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -1,239 +1,240 @@ -"use client"; - -import { Plus } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { AspectRatio } from "@/components/ui/aspect-ratio"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useEditor } from "@/editor/use-editor"; -import { clearDragData, setDragData } from "@/timeline/drag-data"; -import type { TimelineDragData } from "@/timeline/drag"; -import { cn } from "@/utils/ui"; - -export interface DraggableItemProps { - name: string; - preview: ReactNode; - dragData: TimelineDragData; - onDragStart?: ({ e }: { e: React.DragEvent }) => void; - onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; - aspectRatio?: number; - className?: string; - containerClassName?: string; - shouldShowPlusOnDrag?: boolean; - shouldShowLabel?: boolean; - isRounded?: boolean; - variant?: "card" | "compact"; - isDraggable?: boolean; -} - -export function DraggableItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - containerClassName, - shouldShowPlusOnDrag = true, - shouldShowLabel = true, - isRounded = true, - variant = "card", - isDraggable = true, -}: DraggableItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef(null); - const editor = useEditor(); - - const handleAddToTimeline = () => { - onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); - }; - - const emptyImg = new window.Image(); - emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - - useEffect(() => { - if (!isDragging) return; - - const handleDragOver = (e: DragEvent) => { - setDragPosition({ x: e.clientX, y: e.clientY }); - }; - - document.addEventListener("dragover", handleDragOver); - - return () => { - document.removeEventListener("dragover", handleDragOver); - }; - }, [isDragging]); - - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setDragImage(emptyImg, 0, 0); - - setDragData({ dataTransfer: e.dataTransfer, dragData }); - e.dataTransfer.effectAllowed = "copy"; - - setDragPosition({ x: e.clientX, y: e.clientY }); - setIsDragging(true); - - onDragStart?.({ e }); - }; - - const handleDragEnd = () => { - setIsDragging(false); - clearDragData(); - }; - - return ( - <> - {variant === "card" ? ( -
-
- - {preview} - {!isDragging && ( - - )} - - {shouldShowLabel && ( - - {name} - - - )} -
-
- ) : ( -
- -
- )} - - {isDraggable && - isDragging && - typeof document !== "undefined" && - createPortal( -
-
- -
- {preview} -
- {shouldShowPlusOnDrag && ( - - )} -
-
-
, - document.body, - )} - - ); -} - -function PlusButton({ - className, - onClick, - tooltipText, -}: { - className?: string; - onClick?: () => void; - tooltipText?: string; -}) { - const button = ( - - ); - - if (tooltipText) { - return ( - - {button} - -

{tooltipText}

-
-
- ); - } - - return button; -} +"use client"; + +import { Plus } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/editor/use-editor"; +import type { TimelineDragData } from "@/timeline/drag"; +import { cn } from "@/utils/ui"; + +export interface DraggableItemProps { + name: string; + preview: ReactNode; + dragData: TimelineDragData; + onDragStart?: ({ e }: { e: React.DragEvent }) => void; + onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; + aspectRatio?: number; + className?: string; + containerClassName?: string; + shouldShowPlusOnDrag?: boolean; + shouldShowLabel?: boolean; + isRounded?: boolean; + variant?: "card" | "compact"; + isDraggable?: boolean; +} + +export function DraggableItem({ + name, + preview, + dragData, + onDragStart, + onAddToTimeline, + aspectRatio = 16 / 9, + className = "", + containerClassName, + shouldShowPlusOnDrag = true, + shouldShowLabel = true, + isRounded = true, + variant = "card", + isDraggable = true, +}: DraggableItemProps) { + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const dragRef = useRef(null); + const editor = useEditor(); + + const handleAddToTimeline = () => { + onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); + }; + + const emptyImg = new window.Image(); + emptyImg.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; + + useEffect(() => { + if (!isDragging) return; + + const handleDragOver = (e: DragEvent) => { + setDragPosition({ x: e.clientX, y: e.clientY }); + }; + + document.addEventListener("dragover", handleDragOver); + + return () => { + document.removeEventListener("dragover", handleDragOver); + }; + }, [isDragging]); + + const handleDragStart = (event: React.DragEvent) => { + event.dataTransfer.setDragImage(emptyImg, 0, 0); + + editor.timeline.dragSource.begin({ + dataTransfer: event.dataTransfer, + dragData, + }); + + setDragPosition({ x: event.clientX, y: event.clientY }); + setIsDragging(true); + + onDragStart?.({ e: event }); + }; + + const handleDragEnd = () => { + setIsDragging(false); + editor.timeline.dragSource.end(); + }; + + return ( + <> + {variant === "card" ? ( +
+
+ + {preview} + {!isDragging && ( + + )} + + {shouldShowLabel && ( + + {name} + + + )} +
+
+ ) : ( +
+ +
+ )} + + {isDraggable && + isDragging && + typeof document !== "undefined" && + createPortal( +
+
+ +
+ {preview} +
+ {shouldShowPlusOnDrag && ( + + )} +
+
+
, + document.body, + )} + + ); +} + +function PlusButton({ + className, + onClick, + tooltipText, +}: { + className?: string; + onClick?: () => void; + tooltipText?: string; +}) { + const button = ( + + ); + + if (tooltipText) { + return ( + + {button} + +

{tooltipText}

+
+
+ ); + } + + return button; +} diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 545565be..d0716aad 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -1,9 +1,6 @@ import type { EditorCore } from "@/core"; import { TICKS_PER_SECOND } from "@/wasm"; -import { - clampRetimeRate, - shouldMaintainPitch, -} from "@/retime/rate"; +import { clampRetimeRate, shouldMaintainPitch } from "@/retime/rate"; import type { AudioClipSource } from "@/media/audio"; import { createAudioContext, collectAudioClips } from "@/media/audio"; import { @@ -56,10 +53,8 @@ export class AudioManager { this.editor.playback.subscribe(this.handlePlaybackChange), this.editor.timeline.subscribe(this.handleTimelineChange), this.editor.media.subscribe(this.handleTimelineChange), + this.editor.playback.onSeek(this.handleSeek), ); - if (typeof window !== "undefined") { - window.addEventListener("playback-seek", this.handleSeek); - } } dispose(): void { @@ -68,9 +63,6 @@ export class AudioManager { unsub(); } this.unsubscribers = []; - if (typeof window !== "undefined") { - window.removeEventListener("playback-seek", this.handleSeek); - } this.disposeSinks(); this.preparedClipBuffers.clear(); this.decodedBuffers.clear(); @@ -102,17 +94,14 @@ export class AudioManager { } }; - private handleSeek = (event: Event): void => { - const detail = (event as CustomEvent<{ time: number }>).detail; - if (!detail) return; - + private handleSeek = (time: number): void => { if (this.editor.playback.getIsScrubbing()) { this.stopPlayback(); return; } if (this.editor.playback.getIsPlaying()) { - void this.startPlayback({ time: detail.time / TICKS_PER_SECOND }); + void this.startPlayback({ time: time / TICKS_PER_SECOND }); return; } @@ -126,7 +115,9 @@ export class AudioManager { if (!this.editor.playback.getIsPlaying()) return; - void this.startPlayback({ time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND }); + void this.startPlayback({ + time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND, + }); }; private ensureAudioContext(): AudioContext | null { diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 1ea09826..a243dc19 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -10,6 +10,8 @@ export class PlaybackManager { private previousVolume = 1; private isScrubbing = false; private listeners = new Set<() => void>(); + private updateListeners = new Set<(time: number) => void>(); + private seekListeners = new Set<(time: number) => void>(); private playbackTimer: number | null = null; private playbackStartWallTime = 0; private playbackStartTime = 0; @@ -67,7 +69,7 @@ export class PlaybackManager { this.playbackStartTime = this.currentTime; } this.notify(); - this.dispatchSeekEvent(this.currentTime); + this.notifySeek(this.currentTime); } setVolume({ volume }: { volume: number }): void { @@ -133,6 +135,16 @@ export class PlaybackManager { return () => this.listeners.delete(listener); } + onUpdate(listener: (time: number) => void): () => void { + this.updateListeners.add(listener); + return () => this.updateListeners.delete(listener); + } + + onSeek(listener: (time: number) => void): () => void { + this.seekListeners.add(listener); + return () => this.seekListeners.delete(listener); + } + private reconcileTimelineScope(): void { const maxTime = this.editor.timeline.getTotalDuration(); const nextTime = this.clampTimeToTimeline(this.currentTime); @@ -152,7 +164,7 @@ export class PlaybackManager { this.notify(); if (timeChanged) { - this.dispatchSeekEvent(this.currentTime); + this.notifySeek(this.currentTime); } } @@ -196,12 +208,12 @@ export class PlaybackManager { this.pause(); this.currentTime = maxTime; this.notify(); - this.dispatchSeekEvent(maxTime); + this.notifySeek(maxTime); return; } this.currentTime = newTime; - this.dispatchUpdateEvent(newTime); + this.notifyUpdate(newTime); this.playbackTimer = requestAnimationFrame(this.updateTime); }; @@ -210,27 +222,15 @@ export class PlaybackManager { return Math.max(0, Math.min(maxTime, time)); } - private dispatchSeekEvent(time: number): void { - if (typeof window === "undefined") { - return; + private notifySeek(time: number): void { + for (const fn of this.seekListeners) { + fn(time); } - - window.dispatchEvent( - new CustomEvent("playback-seek", { - detail: { time }, - }), - ); } - private dispatchUpdateEvent(time: number): void { - if (typeof window === "undefined") { - return; + private notifyUpdate(time: number): void { + for (const fn of this.updateListeners) { + fn(time); } - - window.dispatchEvent( - new CustomEvent("playback-update", { - detail: { time }, - }), - ); } } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index dbc3332b..6f54c5aa 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -9,6 +9,7 @@ import type { RetimeConfig, } from "@/timeline"; import { calculateTotalDuration } from "@/timeline"; +import { TimelineDragSource } from "@/timeline/drag-source"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import { canElementBeHidden, @@ -67,6 +68,7 @@ export class TimelineManager { private listeners = new Set<() => void>(); private previewOverlay = new Map>(); private previewTracks: SceneTracks | null = null; + public readonly dragSource = new TimelineDragSource(); constructor(private editor: EditorCore) {} @@ -706,11 +708,11 @@ export class TimelineManager { previewElements({ updates, }: { - updates: Array<{ - trackId: string; - elementId: string; - updates: Partial; - }>; + updates: readonly { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial; + }[]; }): void { let changedOverlayCount = 0; for (const { elementId, updates: elementUpdates } of updates) { diff --git a/apps/web/src/media/use-file-upload.ts b/apps/web/src/media/use-file-upload.ts index 1ee30ad9..c8c62073 100644 --- a/apps/web/src/media/use-file-upload.ts +++ b/apps/web/src/media/use-file-upload.ts @@ -1,5 +1,5 @@ import { useState, useRef } from "react"; -import { hasDragData } from "@/timeline/drag-data"; +import { useEditor } from "@/editor/use-editor"; interface UseFileUploadOptions { accept?: string; @@ -7,19 +7,23 @@ interface UseFileUploadOptions { onFilesSelected?: (files: File[]) => void; } -function containsFiles(dataTransfer: DataTransfer): boolean { - return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files"); -} - export function useFileUpload({ accept, multiple, onFilesSelected, }: UseFileUploadOptions = {}) { + const editor = useEditor(); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const inputRef = useRef(null); + function containsFiles(dataTransfer: DataTransfer): boolean { + return ( + !editor.timeline.dragSource.isActive() && + dataTransfer.types.includes("Files") + ); + } + function openFilePicker() { if (!inputRef.current) return; diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index b44e42ef..783dd239 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -8,12 +8,10 @@ import { EditableTimecode } from "@/components/editable-timecode"; import { Button } from "@/components/ui/button"; import { FullScreenIcon, - GridTableIcon, PauseIcon, PlayIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { getGuideById } from "@/guides"; import { Separator } from "@/components/ui/separator"; import { Select, @@ -24,17 +22,12 @@ import { } from "@/components/ui/select"; import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom"; import { usePreviewViewport } from "./preview-viewport"; -import { GridPopover } from "./guide-popover"; -import { usePreviewStore } from "@/preview/preview-store"; export function PreviewToolbar({ onToggleFullscreen, }: { onToggleFullscreen: () => void; }) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const activeGuideDefinition = getGuideById(activeGuide); - return (
@@ -72,15 +65,13 @@ function TimecodeDisplay() { ); useEffect(() => { - const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); - window.addEventListener("playback-update", handler); - window.addEventListener("playback-seek", handler); + const unsubscribeUpdate = editor.playback.onUpdate(setCurrentTime); + const unsubscribeSeek = editor.playback.onSeek(setCurrentTime); return () => { - window.removeEventListener("playback-update", handler); - window.removeEventListener("playback-seek", handler); + unsubscribeUpdate(); + unsubscribeSeek(); }; - }, []); + }, [editor.playback]); return (
@@ -94,7 +85,11 @@ function TimecodeDisplay() { /> / - {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })} + {formatTimecode({ + time: totalDuration, + format: "HH:MM:SS:FF", + rate: fps, + })}
); diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts new file mode 100644 index 00000000..00573662 --- /dev/null +++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts @@ -0,0 +1,583 @@ +import type { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, +} from "react"; +import type { MediaAsset } from "@/media/types"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/preview/element-bounds"; +import { + getHitElements, + hitTest, + resolvePreferredHit, +} from "@/preview/hit-test"; +import { + SNAP_THRESHOLD_SCREEN_PIXELS, + snapPosition, + type SnapLine, +} from "@/preview/preview-snap"; +import type { TCanvasSize } from "@/project/types"; +import type { Transform } from "@/rendering"; +import { isVisualElement } from "@/timeline/element-utils"; +import type { + ElementRef, + SceneTracks, + TextElement, + TimelineElement, + TimelineTrack, + VisualElement, +} from "@/timeline"; + +const MIN_DRAG_DISTANCE = 0.5; +const PRIMARY_POINTER_BUTTON = 0; + +type Point = { readonly x: number; readonly y: number }; + +interface CapturedPointerState { + readonly pointerId: number; + readonly captureTarget: HTMLElement; +} + +interface PendingGesture extends CapturedPointerState { + readonly kind: "pending"; + readonly origin: Point; + readonly topmostHit: ElementWithBounds | null; + readonly selectedHit: ElementWithBounds | null; + readonly selectedElements: readonly ElementRef[]; +} + +interface DragElementSnapshot { + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; +} + +interface DraggingGesture extends CapturedPointerState { + readonly kind: "dragging"; + readonly origin: Point; + readonly bounds: { + readonly width: number; + readonly height: number; + readonly rotation: number; + }; + readonly elements: readonly DragElementSnapshot[]; +} + +type GestureSession = + | { readonly kind: "idle" } + | PendingGesture + | DraggingGesture; + +const IDLE_GESTURE: GestureSession = { kind: "idle" }; + +export interface EditingTextState { + readonly trackId: string; + readonly elementId: string; + readonly element: TextElement; +} + +export interface PreviewViewportAdapter { + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => Point | null; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => Point; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getTracks: () => SceneTracks; + getCurrentTime: () => number; + getMediaAssets: () => MediaAsset[]; + getCanvasSize: () => TCanvasSize; +} + +export interface SelectionApi { + getSelected: () => readonly ElementRef[]; + setSelected: (elements: readonly ElementRef[]) => void; + clearSelection: () => void; +} + +export interface TimelinePreviewUpdate { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial; +} + +export interface TimelineOps { + getElementsWithTracks: ({ + elements, + }: { + elements: readonly ElementRef[]; + }) => Array<{ track: TimelineTrack; element: TimelineElement }>; + previewElements: (updates: readonly TimelinePreviewUpdate[]) => void; + commitPreview: () => void; + discardPreview: () => void; +} + +export interface PlaybackApi { + getIsPlaying: () => boolean; + subscribe: (listener: () => void) => () => void; +} + +export interface PreviewOptions { + isMaskMode: () => boolean; + onSnapLinesChange?: (lines: SnapLine[]) => void; +} + +export interface PreviewInteractionDeps { + viewport: PreviewViewportAdapter; + input: InputAdapter; + scene: SceneReader; + selection: SelectionApi; + timeline: TimelineOps; + playback: PlaybackApi; + preview: PreviewOptions; +} + +export interface PreviewInteractionDepsRef { + readonly current: PreviewInteractionDeps; +} + +function isSameElementRef({ + left, + right, +}: { + left: ElementRef; + right: ElementRef; +}): boolean { + return left.trackId === right.trackId && left.elementId === right.elementId; +} + +function buildDragSelection({ + selectedElements, + dragTarget, +}: { + selectedElements: readonly ElementRef[]; + dragTarget: ElementWithBounds; +}): ElementRef[] { + const dragTargetRef = { + trackId: dragTarget.trackId, + elementId: dragTarget.elementId, + }; + + if ( + !selectedElements.some((selectedElement) => + isSameElementRef({ left: selectedElement, right: dragTargetRef }), + ) + ) { + return [dragTargetRef]; + } + + return [ + dragTargetRef, + ...selectedElements.filter( + (selectedElement) => + !isSameElementRef({ left: selectedElement, right: dragTargetRef }), + ), + ]; +} + +function movedPastDragThreshold({ + current, + origin, +}: { + current: Point; + origin: Point; +}): boolean { + return ( + Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE || + Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE + ); +} + +function toDragElementSnapshots({ + elementsWithTracks, +}: { + elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>; +}): DragElementSnapshot[] { + const isVisualTrackedElement = (value: { + track: TimelineTrack; + element: TimelineElement; + }): value is { track: TimelineTrack; element: VisualElement } => + isVisualElement(value.element); + + return elementsWithTracks + .filter(isVisualTrackedElement) + .map(({ track, element }) => ({ + trackId: track.id, + elementId: element.id, + initialTransform: element.transform, + })); +} + +export class PreviewInteractionController { + private readonly depsRef: PreviewInteractionDepsRef; + private readonly subscribers = new Set<() => void>(); + + private gesture: GestureSession = IDLE_GESTURE; + private editingTextState: EditingTextState | null = null; + private wasPlaying: boolean; + private unsubscribePlayback: (() => void) | null = null; + + constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) { + this.depsRef = depsRef; + this.wasPlaying = this.deps.playback.getIsPlaying(); + + this.onDoubleClick = this.onDoubleClick.bind(this); + this.onPointerDown = this.onPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + this.commitTextEdit = this.commitTextEdit.bind(this); + this.handlePlaybackChange = this.handlePlaybackChange.bind(this); + + this.unsubscribePlayback = this.deps.playback.subscribe( + this.handlePlaybackChange, + ); + } + + private get deps(): PreviewInteractionDeps { + return this.depsRef.current; + } + + get isDragging(): boolean { + return this.gesture.kind === "dragging"; + } + + get editingText(): EditingTextState | null { + return this.editingTextState; + } + + subscribe({ listener }: { listener: () => void }): () => void { + this.subscribers.add(listener); + return () => this.subscribers.delete(listener); + } + + destroy(): void { + this.unsubscribePlayback?.(); + this.unsubscribePlayback = null; + this.abortActiveGesture(); + this.editingTextState = null; + this.subscribers.clear(); + } + + cancel(): void { + if (this.gesture.kind === "idle") return; + this.abortActiveGesture(); + this.notify(); + } + + private abortActiveGesture(): void { + if (this.gesture.kind === "idle") return; + + if (this.gesture.kind === "dragging") { + this.deps.timeline.discardPreview(); + } + + this.releaseCapturedPointer({ pointerState: this.gesture }); + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + } + + commitTextEdit(): void { + if (!this.editingTextState) return; + + this.editingTextState = null; + this.deps.timeline.commitPreview(); + this.notify(); + } + + onDoubleClick({ clientX, clientY }: ReactMouseEvent): void { + if (this.editingTextState || this.deps.preview.isMaskMode()) return; + + const startPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!startPos) return; + + const hit = hitTest({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + + if (!hit || hit.element.type !== "text") return; + + this.editingTextState = { + trackId: hit.trackId, + elementId: hit.elementId, + element: hit.element, + }; + this.notify(); + } + + onPointerDown({ + clientX, + clientY, + currentTarget, + pointerId, + button, + }: ReactPointerEvent): void { + if (this.editingTextState) return; + if (this.deps.preview.isMaskMode()) return; + if (button !== PRIMARY_POINTER_BUTTON) return; + + const startPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!startPos) return; + + const hits = getHitElements({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + const selectedElements = this.deps.selection.getSelected(); + + this.gesture = { + kind: "pending", + origin: startPos, + pointerId, + captureTarget: currentTarget as HTMLElement, + topmostHit: hits[0] ?? null, + selectedHit: resolvePreferredHit({ + hits, + preferredElements: [...selectedElements], + }), + selectedElements, + }; + + currentTarget.setPointerCapture(pointerId); + } + + onPointerMove({ clientX, clientY }: ReactPointerEvent): void { + const currentPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!currentPos) return; + + if (this.gesture.kind === "pending") { + const pending = this.gesture; + if ( + !movedPastDragThreshold({ + current: currentPos, + origin: pending.origin, + }) + ) { + this.clearSnapLines(); + return; + } + + this.beginDragFromPending({ pending }); + } + + if (this.gesture.kind !== "dragging") return; + + this.updateDragPreview({ + drag: this.gesture, + currentPos, + }); + } + + onPointerUp({ type }: ReactPointerEvent): void { + if (this.gesture.kind === "dragging") { + const drag = this.gesture; + + if (type === "pointercancel") { + this.deps.timeline.discardPreview(); + } else { + this.deps.timeline.commitPreview(); + } + + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: drag }); + this.notify(); + return; + } + + if (this.gesture.kind !== "pending") return; + + const pending = this.gesture; + + if (type !== "pointercancel") { + const clickTarget = pending.topmostHit; + if (!clickTarget) { + this.deps.selection.clearSelection(); + } else { + this.deps.selection.setSelected([ + { + trackId: clickTarget.trackId, + elementId: clickTarget.elementId, + }, + ]); + } + } + + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + } + + private notify(): void { + for (const listener of this.subscribers) listener(); + } + + private clearSnapLines(): void { + this.deps.preview.onSnapLinesChange?.([]); + } + + private releaseCapturedPointer({ + pointerState, + }: { + pointerState: CapturedPointerState | null; + }): void { + if (!pointerState) return; + + if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) { + return; + } + + pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + } + + private getVisibleElementsWithBounds(): ElementWithBounds[] { + return getVisibleElementsWithBounds({ + tracks: this.deps.scene.getTracks(), + currentTime: this.deps.scene.getCurrentTime(), + canvasSize: this.deps.scene.getCanvasSize(), + mediaAssets: this.deps.scene.getMediaAssets(), + }); + } + + private handlePlaybackChange(): void { + const isPlaying = this.deps.playback.getIsPlaying(); + if (isPlaying && !this.wasPlaying && this.editingTextState) { + this.commitTextEdit(); + } + this.wasPlaying = isPlaying; + } + + private beginDragFromPending({ pending }: { pending: PendingGesture }): void { + const dragTarget = pending.selectedHit ?? pending.topmostHit; + if (!dragTarget) { + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + return; + } + + const dragSelection = buildDragSelection({ + selectedElements: pending.selectedElements, + dragTarget, + }); + const draggableElements = toDragElementSnapshots({ + elementsWithTracks: this.deps.timeline.getElementsWithTracks({ + elements: dragSelection, + }), + }); + + if (draggableElements.length === 0) { + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + return; + } + + if (pending.selectedHit === null) { + this.deps.selection.setSelected([ + { + trackId: dragTarget.trackId, + elementId: dragTarget.elementId, + }, + ]); + } + + this.gesture = { + kind: "dragging", + origin: pending.origin, + pointerId: pending.pointerId, + captureTarget: pending.captureTarget, + bounds: { + width: dragTarget.bounds.width, + height: dragTarget.bounds.height, + rotation: dragTarget.bounds.rotation, + }, + elements: draggableElements, + }; + this.notify(); + } + + private updateDragPreview({ + drag, + currentPos, + }: { + drag: DraggingGesture; + currentPos: Point; + }): void { + const firstElement = drag.elements[0]; + if (!firstElement) return; + + const deltaX = currentPos.x - drag.origin.x; + const deltaY = currentPos.y - drag.origin.y; + + const proposedPosition = { + x: firstElement.initialTransform.position.x + deltaX, + y: firstElement.initialTransform.position.y + deltaY, + }; + + const shouldSnap = !this.deps.input.isShiftHeld(); + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedPosition, activeLines } = shouldSnap + ? snapPosition({ + proposedPosition, + canvasSize: this.deps.scene.getCanvasSize(), + elementSize: drag.bounds, + rotation: drag.bounds.rotation, + snapThreshold, + }) + : { + snappedPosition: proposedPosition, + activeLines: [] as SnapLine[], + }; + + this.deps.preview.onSnapLinesChange?.(activeLines); + + const deltaSnappedX = + snappedPosition.x - firstElement.initialTransform.position.x; + const deltaSnappedY = + snappedPosition.y - firstElement.initialTransform.position.y; + + this.deps.timeline.previewElements( + drag.elements.map(({ trackId, elementId, initialTransform }) => ({ + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + position: { + x: initialTransform.position.x + deltaSnappedX, + y: initialTransform.position.y + deltaSnappedY, + }, + }, + }, + })), + ); + } +} diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts new file mode 100644 index 00000000..e9d251e3 --- /dev/null +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -0,0 +1,753 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import type { MediaAsset } from "@/media/types"; +import { + getVisibleElementsWithBounds, + type Corner, + type Edge, + type ElementBounds, + type ElementWithBounds, +} from "@/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/preview/preview-snap"; +import { isVisualElement } from "@/timeline/element-utils"; +import { + getElementLocalTime, + hasKeyframesForPath, + resolveTransformAtTime, + setChannel, +} from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; +import type { Transform } from "@/rendering"; +import type { + ElementRef, + SceneTracks, + TimelineElement, + VisualElement, +} from "@/timeline"; + +type Point = { readonly x: number; readonly y: number }; +type CanvasSize = { readonly width: number; readonly height: number }; +type HandleType = Corner | Edge | "rotation"; + +interface CapturedPointerState { + readonly pointerId: number; + readonly captureTarget: HTMLElement; +} + +interface CornerScaleSession extends CapturedPointerState { + readonly kind: "corner-scale"; + readonly corner: Corner; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialDistance: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface EdgeScaleSession extends CapturedPointerState { + readonly kind: "edge-scale"; + readonly edge: Edge; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly rotationRad: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationSession extends CapturedPointerState { + readonly kind: "rotation"; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialAngle: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; +} + +type TransformSession = + | { readonly kind: "idle" } + | CornerScaleSession + | EdgeScaleSession + | RotationSession; + +const IDLE_SESSION: TransformSession = { kind: "idle" }; + +interface VisualSelectionContext { + readonly trackId: string; + readonly elementId: string; + readonly element: VisualElement; + readonly bounds: ElementBounds; + readonly resolvedTransform: Transform; +} + +export interface PreviewViewportAdapter { + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => Point | null; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => Point; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getSelectedElements: () => readonly ElementRef[]; + getTracks: () => SceneTracks; + getCurrentTime: () => number; + getMediaAssets: () => MediaAsset[]; + getCanvasSize: () => CanvasSize; +} + +export interface TimelinePreviewUpdate { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial; +} + +export interface TimelineOps { + previewElements: (updates: readonly TimelinePreviewUpdate[]) => void; + commitPreview: () => void; + discardPreview: () => void; +} + +export interface PreviewOptions { + onSnapLinesChange?: (lines: SnapLine[]) => void; +} + +export interface TransformHandleDeps { + viewport: PreviewViewportAdapter; + input: InputAdapter; + scene: SceneReader; + timeline: TimelineOps; + preview: PreviewOptions; +} + +export interface TransformHandleDepsRef { + readonly current: TransformHandleDeps; +} + +function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): number { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +function buildSelectedWithBounds({ + selectedElements, + elementsWithBounds, +}: { + selectedElements: readonly ElementRef[]; + elementsWithBounds: readonly ElementWithBounds[]; +}): ElementWithBounds | null { + if (selectedElements.length !== 1) return null; + + return ( + elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null + ); +} + +function buildCornerScaleAnimationReset({ + animations, +}: { + animations: ElementAnimations | undefined; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleX", + }) || + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleY", + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : animations, + }; +} + +function buildEdgeScaleAnimationReset({ + animations, + edge, +}: { + animations: ElementAnimations | undefined; + edge: Edge; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + + const shouldClearScaleAnimation = hasKeyframesForPath({ + animations, + propertyPath, + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations, + propertyPath, + channel: undefined, + }) + : animations, + }; +} + +export class TransformHandleController { + private readonly depsRef: TransformHandleDepsRef; + private readonly subscribers = new Set<() => void>(); + + private session: TransformSession = IDLE_SESSION; + + constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) { + this.depsRef = depsRef; + + this.onCornerPointerDown = this.onCornerPointerDown.bind(this); + this.onEdgePointerDown = this.onEdgePointerDown.bind(this); + this.onRotationPointerDown = this.onRotationPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + } + + private get deps(): TransformHandleDeps { + return this.depsRef.current; + } + + get selectedWithBounds(): ElementWithBounds | null { + return buildSelectedWithBounds({ + selectedElements: this.deps.scene.getSelectedElements(), + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + } + + get activeHandle(): HandleType | null { + switch (this.session.kind) { + case "corner-scale": + return this.session.corner; + case "edge-scale": + return this.session.edge; + case "rotation": + return "rotation"; + default: + return null; + } + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.session.kind !== "idle") { + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + } + + this.subscribers.clear(); + } + + cancel(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + onCornerPointerDown({ + event, + corner, + }: { + event: ReactPointerEvent; + corner: Corner; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildCornerScaleAnimationReset({ + animations: context.element.animations, + }); + + this.session = { + kind: "corner-scale", + corner, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialDistance: getCornerDistance({ + bounds: context.bounds, + corner, + }), + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onRotationPointerDown({ event }: { event: ReactPointerEvent }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + const deltaX = position.x - context.bounds.cx; + const deltaY = position.y - context.bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + this.session = { + kind: "rotation", + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialAngle, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onEdgePointerDown({ + event, + edge, + }: { + event: ReactPointerEvent; + edge: Edge; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildEdgeScaleAnimationReset({ + animations: context.element.animations, + edge, + }); + + this.session = { + kind: "edge-scale", + edge, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + rotationRad: (context.bounds.rotation * Math.PI) / 180, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onPointerMove({ event }: { event: ReactPointerEvent }): void { + if (this.session.kind === "idle") return; + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + switch (this.session.kind) { + case "corner-scale": + this.previewCornerScale({ + session: this.session, + position, + }); + return; + case "edge-scale": + this.previewEdgeScale({ + session: this.session, + position, + }); + return; + case "rotation": + this.previewRotation({ + session: this.session, + position, + }); + return; + default: + return; + } + } + + onPointerUp(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.commitPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private clearSnapLines(): void { + this.deps.preview.onSnapLinesChange?.([]); + } + + private capturePointer({ + target, + pointerId, + }: { + target: HTMLElement; + pointerId: number; + }): HTMLElement { + target.setPointerCapture(pointerId); + return target; + } + + private releaseCapturedPointer(pointerState: CapturedPointerState): void { + if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) { + return; + } + + pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + } + + private getVisibleElementsWithBounds(): ElementWithBounds[] { + return getVisibleElementsWithBounds({ + tracks: this.deps.scene.getTracks(), + currentTime: this.deps.scene.getCurrentTime(), + canvasSize: this.deps.scene.getCanvasSize(), + mediaAssets: this.deps.scene.getMediaAssets(), + }); + } + + private getSelectedVisualContext(): VisualSelectionContext | null { + const selectedWithBounds = this.selectedWithBounds; + if (!selectedWithBounds) return null; + if (!isVisualElement(selectedWithBounds.element)) return null; + + const localTime = getElementLocalTime({ + timelineTime: this.deps.scene.getCurrentTime(), + elementStartTime: selectedWithBounds.element.startTime, + elementDuration: selectedWithBounds.element.duration, + }); + + return { + trackId: selectedWithBounds.trackId, + elementId: selectedWithBounds.elementId, + element: selectedWithBounds.element, + bounds: selectedWithBounds.bounds, + resolvedTransform: resolveTransformAtTime({ + baseTransform: selectedWithBounds.element.transform, + animations: selectedWithBounds.element.animations, + localTime, + }), + }; + } + + private previewCornerScale({ + session, + position, + }: { + session: CornerScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / session.initialDistance; + + // Use actual element dimensions (base * current scale) so snapping is + // computed from the rendered geometry when scaleX != scaleY. + const effectiveWidth = session.baseWidth * session.initialTransform.scaleX; + const effectiveHeight = + session.baseHeight * session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale, activeLines } = this.deps.input.isShiftHeld() + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: session.initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + }); + + this.deps.preview.onSnapLinesChange?.(activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: clampScaleNonZero( + session.initialTransform.scaleX * snappedScale, + ), + scaleY: clampScaleNonZero( + session.initialTransform.scaleY * snappedScale, + ), + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewEdgeScale({ + session, + position, + }: { + session: EdgeScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const xProjection = + deltaX * Math.cos(session.rotationRad) + + deltaY * Math.sin(session.rotationRad); + const yProjection = + -deltaX * Math.sin(session.rotationRad) + + deltaY * Math.cos(session.rotationRad); + const projection = + session.edge === "right" + ? xProjection + : session.edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + session.edge === "right" || session.edge === "left" + ? session.baseWidth / 2 + : session.baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + session.edge === "right" || session.edge === "left" + ? proposedScale + : session.initialTransform.scaleX; + const proposedScaleY = + session.edge === "bottom" + ? proposedScale + : session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld() + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: session.initialTransform.position, + baseWidth: session.baseWidth, + baseHeight: session.baseHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + preferredEdges: getPreferredEdge({ edge: session.edge }), + }); + + const relevantSnap = + session.edge === "right" || session.edge === "left" ? xSnap : ySnap; + this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: + session.edge === "right" || session.edge === "left" + ? xSnap.snappedScale + : session.initialTransform.scaleX, + scaleY: + session.edge === "bottom" + ? ySnap.snappedScale + : session.initialTransform.scaleY, + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewRotation({ + session, + position, + }: { + session: RotationSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - session.initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + const newRotate = session.initialTransform.rotate + deltaAngle; + const { snappedRotation } = this.deps.input.isShiftHeld() + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + rotate: snappedRotation, + }, + }, + }, + ]); + } +} diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts index 78212e74..d093dcf2 100644 --- a/apps/web/src/preview/hooks/use-preview-interaction.ts +++ b/apps/web/src/preview/hooks/use-preview-interaction.ts @@ -1,97 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; -import type { Transform } from "@/rendering"; -import type { ElementRef, TextElement } from "@/timeline"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/preview/element-bounds"; -import { - getHitElements, - hitTest, - resolvePreferredHit, -} from "@/preview/hit-test"; -import { isVisualElement } from "@/timeline/element-utils"; -import { - SNAP_THRESHOLD_SCREEN_PIXELS, - snapPosition, - type SnapLine, -} from "@/preview/preview-snap"; +import type { SnapLine } from "@/preview/preview-snap"; import { registerCanceller } from "@/editor/cancel-interaction"; +import { + PreviewInteractionController, + type PreviewInteractionDeps, + type PreviewInteractionDepsRef, +} from "@/preview/controllers/preview-interaction-controller"; export type OnSnapLinesChange = (lines: SnapLine[]) => void; -const MIN_DRAG_DISTANCE = 0.5; - -interface CapturedPointerState { - pointerId: number; - captureTarget: HTMLElement; -} - -interface PendingGestureState extends CapturedPointerState { - startX: number; - startY: number; - topmostHit: ElementWithBounds | null; - selectedHit: ElementWithBounds | null; - selectedElements: ElementRef[]; -} - -interface DragState extends CapturedPointerState { - startX: number; - startY: number; - bounds: { - width: number; - height: number; - rotation: number; - }; - elements: Array<{ - trackId: string; - elementId: string; - initialTransform: Transform; - }>; -} - -function isSameElementRef({ - left, - right, -}: { - left: ElementRef; - right: ElementRef; -}): boolean { - return left.trackId === right.trackId && left.elementId === right.elementId; -} - -function buildDragSelection({ - selectedElements, - dragTarget, -}: { - selectedElements: ElementRef[]; - dragTarget: ElementWithBounds; -}): ElementRef[] { - const dragTargetRef = { - trackId: dragTarget.trackId, - elementId: dragTarget.elementId, - }; - - if ( - !selectedElements.some((selectedElement) => - isSameElementRef({ left: selectedElement, right: dragTargetRef }), - ) - ) { - return [dragTargetRef]; - } - - return [ - dragTargetRef, - ...selectedElements.filter( - (selectedElement) => - !isSameElementRef({ left: selectedElement, right: dragTargetRef }), - ), - ]; -} - export function usePreviewInteraction({ onSnapLinesChange, isMaskMode = false, @@ -102,355 +22,74 @@ export function usePreviewInteraction({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const viewport = usePreviewViewport(); - const [isDragging, setIsDragging] = useState(false); - const [editingText, setEditingText] = useState<{ - trackId: string; - elementId: string; - element: TextElement; - originalOpacity: number; - } | null>(null); - const dragStateRef = useRef(null); - const pendingGestureRef = useRef(null); - const wasPlayingRef = useRef(editor.playback.getIsPlaying()); - const editingTextRef = useRef(editingText); - editingTextRef.current = editingText; - - const releaseCapturedPointer = useCallback( - (pointerState: CapturedPointerState | null) => { - if (!pointerState) return; - - if ( - !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId) - ) { - return; - } - - pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + const deps: PreviewInteractionDeps = { + viewport: { + screenToCanvas: viewport.screenToCanvas, + screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold, }, - [], - ); + input: { + isShiftHeld: () => isShiftHeldRef.current, + }, + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + getCanvasSize: () => editor.project.getActive().settings.canvasSize, + }, + selection: { + getSelected: () => editor.selection.getSelectedElements(), + setSelected: (elements) => + editor.selection.setSelectedElements({ elements: [...elements] }), + clearSelection: () => editor.selection.clearSelection(), + }, + timeline: { + getElementsWithTracks: ({ elements }) => + editor.timeline.getElementsWithTracks({ elements: [...elements] }), + previewElements: (updates) => + editor.timeline.previewElements({ updates: [...updates] }), + commitPreview: () => editor.timeline.commitPreview(), + discardPreview: () => editor.timeline.discardPreview(), + }, + playback: { + getIsPlaying: () => editor.playback.getIsPlaying(), + subscribe: (listener) => editor.playback.subscribe(listener), + }, + preview: { + isMaskMode: () => isMaskMode, + onSnapLinesChange, + }, + }; - const commitTextEdit = useCallback(() => { - const current = editingTextRef.current; - if (!current) return; - editingTextRef.current = null; - editor.timeline.commitPreview(); - setEditingText(null); - }, [editor.timeline]); + const depsRef = useRef(deps); + depsRef.current = deps; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new PreviewInteractionController({ + depsRef: depsRef as PreviewInteractionDepsRef, + }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect( + () => controller.subscribe({ listener: rerender }), + [controller], + ); useEffect(() => { - const unsubscribe = editor.playback.subscribe(() => { - const isPlaying = editor.playback.getIsPlaying(); - if (isPlaying && !wasPlayingRef.current && editingTextRef.current) { - commitTextEdit(); - } - wasPlayingRef.current = isPlaying; - }); - return unsubscribe; - }, [editor.playback, commitTextEdit]); + if (!controller.isDragging) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isDragging, controller]); - useEffect(() => { - if (!isDragging) return; - - return registerCanceller({ - fn: () => { - const dragState = dragStateRef.current; - if (!dragState) return; - - editor.timeline.discardPreview(); - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - }, - }); - }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]); - - const handleDoubleClick = useCallback( - ({ clientX, clientY }: React.MouseEvent) => { - if (editingText || isMaskMode) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hit = hitTest({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - - if (!hit || hit.element.type !== "text") return; - - const textElement = hit.element as TextElement; - setEditingText({ - trackId: hit.trackId, - elementId: hit.elementId, - element: textElement, - originalOpacity: textElement.opacity, - }); - }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerDown = useCallback( - ({ - clientX, - clientY, - currentTarget, - pointerId, - button, - }: React.PointerEvent) => { - if (editingText) return; - if (isMaskMode) return; - if (button !== 0) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hits = getHitElements({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - const selectedElements = editor.selection.getSelectedElements(); - const topmostHit = hits[0] ?? null; - - pendingGestureRef.current = { - startX: startPos.x, - startY: startPos.y, - pointerId, - captureTarget: currentTarget as HTMLElement, - topmostHit, - selectedHit: resolvePreferredHit({ - hits, - preferredElements: selectedElements, - }), - selectedElements, - }; - currentTarget.setPointerCapture(pointerId); - }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerMove = useCallback( - ({ clientX, clientY }: React.PointerEvent) => { - const canvasSize = editor.project.getActive().settings.canvasSize; - - const currentPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!currentPos) return; - - let dragState = dragStateRef.current; - - if (!dragState) { - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; - - const deltaX = currentPos.x - pendingGesture.startX; - const deltaY = currentPos.y - pendingGesture.startY; - const hasMovement = - Math.abs(deltaX) > MIN_DRAG_DISTANCE || - Math.abs(deltaY) > MIN_DRAG_DISTANCE; - - if (!hasMovement) { - onSnapLinesChange?.([]); - return; - } - - const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit; - if (!dragTarget) { - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - return; - } - - const dragSelection = buildDragSelection({ - selectedElements: pendingGesture.selectedElements, - dragTarget, - }); - const elementsWithTracks = editor.timeline.getElementsWithTracks({ - elements: dragSelection, - }); - const draggableElements = elementsWithTracks.filter(({ element }) => - isVisualElement(element), - ); - - if (draggableElements.length === 0) { - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - return; - } - - if (pendingGesture.selectedHit === null) { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: dragTarget.trackId, - elementId: dragTarget.elementId, - }, - ], - }); - } - - dragState = { - startX: pendingGesture.startX, - startY: pendingGesture.startY, - pointerId: pendingGesture.pointerId, - captureTarget: pendingGesture.captureTarget, - bounds: { - width: dragTarget.bounds.width, - height: dragTarget.bounds.height, - rotation: dragTarget.bounds.rotation, - }, - elements: draggableElements.map(({ track, element }) => ({ - trackId: track.id, - elementId: element.id, - initialTransform: (element as { transform: Transform }).transform, - })), - }; - dragStateRef.current = dragState; - pendingGestureRef.current = null; - setIsDragging(true); - } - - const deltaX = currentPos.x - dragState.startX; - const deltaY = currentPos.y - dragState.startY; - const firstElement = dragState.elements[0]; - const proposedPosition = { - x: firstElement.initialTransform.position.x + deltaX, - y: firstElement.initialTransform.position.y + deltaY, - }; - - const shouldSnap = !isShiftHeldRef.current; - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedPosition, activeLines } = shouldSnap - ? snapPosition({ - proposedPosition, - canvasSize, - elementSize: dragState.bounds, - rotation: dragState.bounds.rotation, - snapThreshold, - }) - : { - snappedPosition: proposedPosition, - activeLines: [] as SnapLine[], - }; - - onSnapLinesChange?.(activeLines); - - const deltaSnappedX = - snappedPosition.x - firstElement.initialTransform.position.x; - const deltaSnappedY = - snappedPosition.y - firstElement.initialTransform.position.y; - - const updates = dragState.elements.map( - ({ trackId, elementId, initialTransform }) => ({ - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: { - x: initialTransform.position.x + deltaSnappedX, - y: initialTransform.position.y + deltaSnappedY, - }, - }, - }, - }), - ); - - editor.timeline.previewElements({ updates }); - }, - [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport], - ); - - const handlePointerUp = useCallback( - ({ type }: React.PointerEvent) => { - const dragState = dragStateRef.current; - if (dragState) { - if (type === "pointercancel") { - editor.timeline.discardPreview(); - } else { - editor.timeline.commitPreview(); - } - - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - return; - } - - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; - - if (type !== "pointercancel") { - const clickTarget = pendingGesture.topmostHit; - if (!clickTarget) { - editor.selection.clearSelection(); - } else { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: clickTarget.trackId, - elementId: clickTarget.elementId, - }, - ], - }); - } - } - - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - }, - [editor, onSnapLinesChange, releaseCapturedPointer], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - onPointerDown: handlePointerDown, - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - onDoubleClick: handleDoubleClick, - editingText, - commitTextEdit, + onPointerDown: controller.onPointerDown, + onPointerMove: controller.onPointerMove, + onPointerUp: controller.onPointerUp, + onDoubleClick: controller.onDoubleClick, + editingText: controller.editingText, + commitTextEdit: controller.commitTextEdit, }; } diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts index 030e026c..f4b7e306 100644 --- a/apps/web/src/preview/hooks/use-transform-handles.ts +++ b/apps/web/src/preview/hooks/use-transform-handles.ts @@ -1,629 +1,84 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/preview/element-bounds"; -import { - MIN_SCALE, - SNAP_THRESHOLD_SCREEN_PIXELS, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/preview/preview-snap"; -import { isVisualElement } from "@/timeline/element-utils"; -import { - getElementLocalTime, - hasKeyframesForPath, - resolveTransformAtTime, - setChannel, -} from "@/animation"; -import type { Transform } from "@/rendering"; -import type { ElementAnimations } from "@/animation/types"; import { registerCanceller } from "@/editor/cancel-interaction"; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -type Edge = "right" | "left" | "bottom"; -type HandleType = Corner | Edge | "rotation"; - -function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference { - return edge === "right" - ? { right: true } - : edge === "left" - ? { left: true } - : { bottom: true }; -} - -interface ScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialDistance: number; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -interface RotationState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialAngle: number; - initialBoundsCx: number; - initialBoundsCy: number; -} - -interface EdgeScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - edge: Edge; - rotationRad: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -function clampScaleNonZero(scale: number): number { - if (Math.abs(scale) < MIN_SCALE) { - return scale < 0 ? -MIN_SCALE : MIN_SCALE; - } - return scale; -} - -function getCornerDistance({ - bounds, - corner, -}: { - bounds: { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; - }; - corner: Corner; -}): number { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; - const localY = - corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; - - const rotatedX = localX * cos - localY * sin; - const rotatedY = localX * sin + localY * cos; - return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; -} +import { + TransformHandleController, + type TransformHandleDeps, +} from "@/preview/controllers/transform-handle-controller"; export function useTransformHandles({ onSnapLinesChange, }: { onSnapLinesChange?: OnSnapLinesChange; }) { + const viewport = usePreviewViewport(); const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandle, setActiveHandle] = useState(null); - const scaleStateRef = useRef(null); - const rotationStateRef = useRef(null); - const edgeScaleStateRef = useRef(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( (e) => e.project.getActive().settings.canvasSize, ); + const deps: TransformHandleDeps = { + viewport, + input: { + isShiftHeld: () => isShiftHeldRef.current, + }, + scene: { + getSelectedElements: () => selectedElements, + getTracks: () => tracks, + getCurrentTime: () => currentTime, + getMediaAssets: () => mediaAssets, + getCanvasSize: () => canvasSize, + }, + timeline: { + previewElements: (updates) => + editor.timeline.previewElements({ updates }), + commitPreview: () => editor.timeline.commitPreview(), + discardPreview: () => editor.timeline.discardPreview(), + }, + preview: { + onSnapLinesChange, + }, + }; - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); + const depsRef = useRef(deps); + depsRef.current = deps; - const selectedWithBounds: ElementWithBounds | null = - selectedElements.length === 1 - ? (elementsWithBounds.find( - (entry) => - entry.trackId === selectedElements[0].trackId && - entry.elementId === selectedElements[0].elementId, - ) ?? null) - : null; + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new TransformHandleController({ depsRef }); + } + const controller = controllerRef.current; - const hasVisualSelection = - selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); - - const clearActiveHandleState = useCallback(() => { - scaleStateRef.current = null; - rotationStateRef.current = null; - edgeScaleStateRef.current = null; - setActiveHandle(null); - onSnapLinesChange?.([]); - }, [onSnapLinesChange]); - - const releaseCapturedPointer = useCallback(() => { - const capture = captureRef.current; - if (!capture) return; - - if (capture.element.hasPointerCapture(capture.pointerId)) { - capture.element.releasePointerCapture(capture.pointerId); - } - - captureRef.current = null; - }, []); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!activeHandle) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller, controller.isActive]); - return registerCanceller({ - fn: () => { - editor.timeline.discardPreview(); - clearActiveHandleState(); - releaseCapturedPointer(); - }, - }); - }, [ - activeHandle, - clearActiveHandleState, - editor.timeline, - releaseCapturedPointer, - ]); + useEffect(() => () => controller.destroy(), [controller]); - const handleCornerPointerDown = useCallback( - ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const shouldClearScaleAnimation = - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleX", - }) || - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleY", - }); - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: setChannel({ - animations: element.animations, - propertyPath: "transform.scaleX", - channel: undefined, - }), - propertyPath: "transform.scaleY", - channel: undefined, - }) - : element.animations; - - scaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialDistance, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(corner); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handleRotationPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - const deltaX = position.x - bounds.cx; - const deltaY = position.y - bounds.cy; - const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - - rotationStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialAngle, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - }; - setActiveHandle("rotation"); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds, viewport], - ); - - const handleEdgePointerDown = useCallback( - ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const rotationRad = (bounds.rotation * Math.PI) / 180; - - const propertyPath = - edge === "right" || edge === "left" - ? "transform.scaleX" - : "transform.scaleY"; - const shouldClearScaleAnimation = hasKeyframesForPath({ - animations: element.animations, - propertyPath, - }); - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: element.animations, - propertyPath, - channel: undefined, - }) - : element.animations; - - edgeScaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(edge); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if ( - !scaleStateRef.current && - !rotationStateRef.current && - !edgeScaleStateRef.current - ) - return; - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - - if ( - scaleStateRef.current && - activeHandle && - activeHandle !== "rotation" - ) { - const { - trackId, - elementId, - initialTransform, - initialDistance, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - } = scaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = - Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; - const scaleFactor = currentDistance / initialDistance; - - // Use actual element dimensions (base * current scale) so snap - // computes the correct edges when scaleX ≠ scaleY - const effectiveWidth = baseWidth * initialTransform.scaleX; - const effectiveHeight = baseHeight * initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedScale: snappedFactor, activeLines } = - isShiftHeldRef.current - ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } - : snapScale({ - proposedScale: scaleFactor, - position: initialTransform.position, - baseWidth: effectiveWidth, - baseHeight: effectiveHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: clampScaleNonZero( - initialTransform.scaleX * snappedFactor, - ), - scaleY: clampScaleNonZero( - initialTransform.scaleY * snappedFactor, - ), - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if ( - edgeScaleStateRef.current && - (activeHandle === "right" || - activeHandle === "left" || - activeHandle === "bottom") - ) { - const { - trackId, - elementId, - initialTransform, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - } = edgeScaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const xProjection = - deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); - const yProjection = - -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); - const projection = - edge === "right" - ? xProjection - : edge === "left" - ? -xProjection - : yProjection; - - const baseAxisHalf = - edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; - const proposedScale = clampScaleNonZero(projection / baseAxisHalf); - - const proposedScaleX = - edge === "right" || edge === "left" - ? proposedScale - : initialTransform.scaleX; - const proposedScaleY = - edge === "bottom" ? proposedScale : initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { x: xSnap, y: ySnap } = isShiftHeldRef.current - ? { - x: { - snappedScale: proposedScaleX, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - y: { - snappedScale: proposedScaleY, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - } - : snapScaleAxes({ - proposedScaleX, - proposedScaleY, - position: initialTransform.position, - baseWidth, - baseHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - preferredEdges: getPreferredEdge({ edge }), - }); - - const relevantSnap = - edge === "right" || edge === "left" ? xSnap : ySnap; - onSnapLinesChange?.(relevantSnap.activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: - edge === "right" || edge === "left" - ? xSnap.snappedScale - : initialTransform.scaleX, - scaleY: - edge === "bottom" - ? ySnap.snappedScale - : initialTransform.scaleY, - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if (rotationStateRef.current && activeHandle === "rotation") { - const { - trackId, - elementId, - initialTransform, - initialAngle, - initialBoundsCx, - initialBoundsCy, - } = rotationStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - let deltaAngle = currentAngle - initialAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - const newRotate = initialTransform.rotate + deltaAngle; - const { snappedRotation } = isShiftHeldRef.current - ? { snappedRotation: newRotate } - : snapRotation({ proposedRotation: newRotate }); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { ...initialTransform, rotate: snappedRotation }, - }, - }, - ], - }); - } - }, - [ - activeHandle, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if ( - scaleStateRef.current || - rotationStateRef.current || - edgeScaleStateRef.current - ) { - editor.timeline.commitPreview(); - clearActiveHandleState(); - } - releaseCapturedPointer(); - }, [clearActiveHandleState, editor, releaseCapturedPointer]); + const selectedWithBounds = controller.selectedWithBounds; + const hasVisualSelection = selectedWithBounds !== null; return { selectedWithBounds, hasVisualSelection, - activeHandle, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, + activeHandle: controller.activeHandle, + handleCornerPointerDown: controller.onCornerPointerDown, + handleEdgePointerDown: controller.onEdgePointerDown, + handleRotationPointerDown: controller.onRotationPointerDown, + handlePointerMove: controller.onPointerMove, + handlePointerUp: controller.onPointerUp, }; } diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts index 31467065..cad4fc31 100644 --- a/apps/web/src/services/video-cache/service.ts +++ b/apps/web/src/services/video-cache/service.ts @@ -21,6 +21,7 @@ export class VideoCache { private sinks = new Map(); private initPromises = new Map>(); private frameChain = new Map>(); + private seekGenerations = new Map(); async getFrameAt({ mediaId, @@ -36,11 +37,20 @@ export class VideoCache { const sinkData = this.sinks.get(mediaId); if (!sinkData) return null; + const generation = (this.seekGenerations.get(mediaId) ?? 0) + 1; + this.seekGenerations.set(mediaId, generation); + const previous = this.frameChain.get(mediaId) ?? Promise.resolve(); - const current = previous.then(() => - this.resolveFrame({ sinkData, time }), + const current = previous.then(() => { + if (this.seekGenerations.get(mediaId) !== generation) { + return sinkData.currentFrame ?? null; + } + return this.resolveFrame({ sinkData, time }); + }); + this.frameChain.set( + mediaId, + current.catch(() => {}), ); - this.frameChain.set(mediaId, current.catch(() => {})); return current; } @@ -173,18 +183,7 @@ export class VideoCache { if (frame) { sinkData.currentFrame = frame; - - // Aggressively fetch next frame immediately to fill buffer - // This matches the mediaplayer example which fetches 2 frames on start - try { - const { value: next } = await sinkData.iterator.next(); - if (next) { - sinkData.nextFrame = next; - } - } catch (e) { - console.warn("Failed to pre-fetch next frame on seek:", e); - } - + this.startPrefetch({ sinkData }); return frame; } } catch (error) { @@ -313,6 +312,8 @@ export class VideoCache { } this.initPromises.delete(mediaId); + this.frameChain.delete(mediaId); + this.seekGenerations.delete(mediaId); } clearAll(): void { diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index e6a9df49..88fb93b7 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,7 +29,7 @@ import { useState, type ReactNode, } from "react"; -import type { ElementDragState, DropTarget } from "@/timeline"; +import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "@/selection/selection-box"; @@ -287,20 +287,15 @@ export function Timeline() { isReady: tracks.length > 0, }); - const { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, - } = useElementInteraction({ - zoomLevel, - timelineRef, - tracksContainerRef, - tracksScrollRef, - snappingEnabled, - onSnapPointChange: handleSnapPointChange, - }); + const { dragView, handleElementMouseDown, handleElementClick } = + useElementInteraction({ + zoomLevel, + tracksContainerRef, + tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + const isElementDragging = dragView.kind === "dragging"; const { dragState: bookmarkDragState, @@ -391,10 +386,19 @@ export function Timeline() { contentWidth: dynamicTimelineWidth, }); + useEdgeAutoScroll({ + isActive: isElementDragging, + getMouseClientX: () => + dragView.kind === "dragging" ? dragView.currentMouseX : 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + const showSnapIndicator = snappingEnabled && currentSnapPoint !== null && - (dragState.isDragging || bookmarkDragState.isDragging || isResizing); + (isElementDragging || bookmarkDragState.isDragging || isResizing); const { handleTracksMouseDown, @@ -457,9 +461,9 @@ export function Timeline() { headerHeight={timelineHeaderHeight} /> @@ -540,9 +544,7 @@ export function Timeline() { ; - lastMouseXRef: React.RefObject; + dragView: ElementDragView; onResizeStart: React.ComponentProps< typeof TimelineTrackContent >["onResizeStart"]; @@ -776,8 +774,16 @@ function TimelineTrackRows({ [tracks, expandedElementIds], ); + const draggingElementIds = useMemo( + () => + dragView.kind === "dragging" + ? dragView.memberTimeOffsets + : (null as ReadonlyMap | null), + [dragView], + ); const sortedTracks = useMemo(() => { - const draggingElementIds = new Set(dragState.dragElementIds); + if (!draggingElementIds) + return tracks.map((track, index) => ({ track, index })); return [...tracks] .map((track, index) => ({ track, index })) .sort((a, b) => { @@ -791,7 +797,7 @@ function TimelineTrackRows({ if (bHasDragged) return -1; return 0; }); - }, [tracks, dragState.dragElementIds]); + }, [tracks, draggingElementIds]); return ( <> @@ -811,10 +817,7 @@ function TimelineTrackRows({ void; - dragState: ElementDragState; + dragView: ElementDragView; isDropTarget?: boolean; } @@ -226,7 +226,7 @@ export function TimelineElement({ onResizeStart, onElementMouseDown, onElementClick, - dragState, + dragView, isDropTarget = false, }: TimelineElementProps) { const mediaAssets = useEditor((e) => e.media.getAssets()); @@ -252,15 +252,18 @@ export function TimelineElement({ selected.elementId === element.id && selected.trackId === track.id, ); - const isBeingDragged = dragState.dragElementIds.includes(element.id); + const isDragging = dragView.kind === "dragging"; + const dragTimeOffset = isDragging + ? dragView.memberTimeOffsets.get(element.id) + : undefined; + const isBeingDragged = dragTimeOffset !== undefined; const dragOffsetY = - isBeingDragged && dragState.isDragging - ? dragState.currentMouseY - dragState.startMouseY + isDragging && isBeingDragged + ? dragView.currentMouseY - dragView.startMouseY : 0; - const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime + dragTimeOffset + isDragging && isBeingDragged + ? dragView.currentTime + dragTimeOffset : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; @@ -382,7 +385,7 @@ export function TimelineElement({ ? `${baseTrackHeight + expansionHeight}px` : "100%", transform: - isBeingDragged && dragState.isDragging + isBeingDragged && isDragging ? `translate3d(0, ${dragOffsetY}px, 0)` : undefined, }} diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx index 6c40637e..7608e6df 100644 --- a/apps/web/src/timeline/components/timeline-track.tsx +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -5,18 +5,12 @@ import { TimelineElement } from "./timeline-element"; import type { TimelineTrack } from "@/timeline"; import type { TimelineElement as TimelineElementType } from "@/timeline"; import { TIMELINE_LAYERS } from "./layers"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; -import type { ElementDragState } from "@/timeline"; -import { useEditor } from "@/editor/use-editor"; +import type { ElementDragView } from "@/timeline"; interface TimelineTrackContentProps { track: TimelineTrack; zoomLevel: number; - dragState: ElementDragState; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - lastMouseXRef: React.RefObject; + dragView: ElementDragView; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; @@ -42,10 +36,7 @@ interface TimelineTrackContentProps { export function TimelineTrackContent({ track, zoomLevel, - dragState, - rulerScrollRef, - tracksScrollRef, - lastMouseXRef, + dragView, onResizeStart, onElementMouseDown, onElementClick, @@ -55,15 +46,6 @@ export function TimelineTrackContent({ targetElementId = null, }: TimelineTrackContentProps) { const { isElementSelected } = useElementSelection(); - const duration = useEditor((e) => e.timeline.getTotalDuration()); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current ?? 0, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, - }); return (
@@ -120,7 +102,7 @@ export function TimelineTrackContent({ onElementClick={(event, element) => onElementClick({ event, element, track }) } - dragState={dragState} + dragView={dragView} isDropTarget={element.id === targetElementId} /> ); diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts new file mode 100644 index 00000000..cd03300e --- /dev/null +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -0,0 +1,577 @@ +import type { DragEvent } from "react"; +import { processMediaAssets } from "@/media/processing"; +import { showMediaUploadToast } from "@/media/upload-toast"; +import { + DEFAULT_NEW_ELEMENT_DURATION, + toElementDurationTicks, +} from "@/timeline/creation"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { roundToFrame, type FrameRate } from "opencut-wasm"; +import { + buildTextElement, + buildGraphicElement, + buildStickerElement, + buildElementFromMedia, + buildEffectElement, +} from "@/timeline/element-utils"; +import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; +import { BatchCommand } from "@/commands"; +import type { Command } from "@/commands/base-command"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import type { TimelineDragSource } from "@/timeline/drag-source"; +import type { + TrackType, + DropTarget, + ElementType, + SceneTracks, + TimelineTrack, + CreateTimelineElement, +} from "@/timeline"; +import type { TimelineDragData } from "@/timeline/drag"; +import type { MediaAsset } from "@/media/types"; +import type { ProcessedMediaAsset } from "@/media/processing"; + +// --- Config --- + +export interface DragDropConfig { + zoomLevel: number; + getContainerEl: () => HTMLDivElement | null; + getHeaderEl: () => HTMLElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + getActiveProjectId: () => string | null; + getSceneTracks: () => SceneTracks; + getCurrentPlayheadTime: () => number; + getMediaAssets: () => MediaAsset[]; + dragSource: TimelineDragSource; + addMediaAsset: (args: { + projectId: string; + asset: ProcessedMediaAsset; + }) => Promise; + executeCommand: (command: Command) => void; + insertElement: (args: { + placement: { mode: "explicit"; trackId: string }; + element: CreateTimelineElement; + }) => void; + addClipEffect: (args: { + trackId: string; + elementId: string; + effectType: string; + }) => void; +} + +export interface DragDropConfigRef { + readonly current: DragDropConfig; +} + +// --- State --- + +interface DragOverState { + kind: "over"; + dropTarget: DropTarget | null; + elementType: ElementType | null; +} + +type DragDropState = { kind: "idle" } | DragOverState; + +interface TimelineCoords { + mouseX: number; + mouseY: number; +} + +// --- Pure helpers --- + +function elementTypeFromDrag({ + dragData, +}: { + dragData: TimelineDragData; +}): ElementType { + switch (dragData.type) { + case "text": + return "text"; + case "graphic": + return "graphic"; + case "sticker": + return "sticker"; + case "effect": + return "effect"; + case "media": + return dragData.mediaType; + } +} + +function getTargetElementTypesForDrag({ + dragData, +}: { + dragData: TimelineDragData; +}): string[] | undefined { + if (dragData.type === "effect") return dragData.targetElementTypes; + if (dragData.type === "media") return dragData.targetElementTypes; + return undefined; +} + +function getDurationForDrag({ + dragData, + mediaAssets, +}: { + dragData: TimelineDragData; + mediaAssets: MediaAsset[]; +}): number { + if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION; + const media = mediaAssets.find((asset) => asset.id === dragData.id); + return toElementDurationTicks({ seconds: media?.duration }); +} + +function orderedTracks({ + sceneTracks, +}: { + sceneTracks: SceneTracks; +}): TimelineTrack[] { + return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; +} + +// --- Controller --- + +export class DragDropController { + private state: DragDropState = { kind: "idle" }; + private enterCount = 0; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: DragDropConfigRef; + + constructor(deps: { configRef: DragDropConfigRef }) { + this.configRef = deps.configRef; + this.onDragEnter = this.onDragEnter.bind(this); + this.onDragOver = this.onDragOver.bind(this); + this.onDragLeave = this.onDragLeave.bind(this); + this.onDrop = this.onDrop.bind(this); + } + + private get config(): DragDropConfig { + return this.configRef.current; + } + + get isDragOver(): boolean { + return this.state.kind !== "idle"; + } + + get dropTarget(): DropTarget | null { + return this.state.kind === "over" ? this.state.dropTarget : null; + } + + get dragElementType(): ElementType | null { + return this.state.kind === "over" ? this.state.elementType : null; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + this.subscribers.clear(); + } + + // --- Drag event handlers (bound, stable, passed as React props) --- + + onDragEnter(event: DragEvent): void { + event.preventDefault(); + const hasAsset = this.config.dragSource.isActive(); + const hasFiles = event.dataTransfer.types.includes("Files"); + if (!hasAsset && !hasFiles) return; + + this.enterCount += 1; + if (this.state.kind === "idle") { + this.setOver({ dropTarget: null, elementType: null }); + } + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + + const coords = this.getMouseTimelineCoords({ event }); + if (!coords) return; + + const dragData = this.config.dragSource.getActive(); + const hasFiles = event.dataTransfer.types.includes("Files"); + const isExternal = hasFiles && !dragData; + + if (!dragData) { + if (hasFiles && isExternal) { + this.setOver({ dropTarget: null, elementType: null }); + } + return; + } + + const elementType = elementTypeFromDrag({ dragData }); + const duration = getDurationForDrag({ + dragData, + mediaAssets: this.config.getMediaAssets(), + }); + const targetElementTypes = getTargetElementTypesForDrag({ dragData }); + + const sceneTracks = this.config.getSceneTracks(); + const target = computeDropTarget({ + elementType, + mouseX: coords.mouseX, + mouseY: coords.mouseY, + tracks: sceneTracks, + playheadTime: this.config.getCurrentPlayheadTime(), + isExternalDrop: isExternal, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + targetElementTypes, + }); + + const fps = this.config.getActiveProjectFps(); + target.xPosition = fps + ? (roundToFrame({ time: target.xPosition, rate: fps }) ?? + target.xPosition) + : target.xPosition; + + this.setOver({ dropTarget: target, elementType }); + event.dataTransfer.dropEffect = "copy"; + } + + onDragLeave(event: DragEvent): void { + event.preventDefault(); + if (this.enterCount === 0) return; + this.enterCount -= 1; + if (this.enterCount === 0) { + this.setIdle(); + } + } + + onDrop(event: DragEvent): void { + event.preventDefault(); + this.enterCount = 0; + + const dragData = this.config.dragSource.getActive(); + const hasFiles = event.dataTransfer.files?.length > 0; + if (!dragData && !hasFiles) return; + + const currentTarget = this.dropTarget; + this.setIdle(); + + try { + if (dragData) { + if (!currentTarget) return; + this.executeAssetDrop({ target: currentTarget, dragData }); + return; + } + + const coords = this.getMouseTimelineCoords({ event }); + if (!coords) return; + this.executeFileDrop({ + files: Array.from(event.dataTransfer.files), + mouseX: coords.mouseX, + mouseY: coords.mouseY, + }).catch((error) => { + console.error("Failed to process file drop:", error); + }); + } catch (error) { + console.error("Failed to process drop:", error); + } + } + + // --- Private --- + + private setOver(state: { + dropTarget: DropTarget | null; + elementType: ElementType | null; + }): void { + this.state = { kind: "over", ...state }; + this.notify(); + } + + private setIdle(): void { + this.state = { kind: "idle" }; + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private getMouseTimelineCoords({ + event, + }: { + event: DragEvent; + }): TimelineCoords | null { + const scrollContainer = this.config.getTracksScrollEl(); + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + this.config.getContainerEl()?.getBoundingClientRect(); + if (!referenceRect) return null; + + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const headerHeight = + this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0; + + return { + mouseX: event.clientX - referenceRect.left + scrollLeft, + mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight, + }; + } + + // Shared insertion logic — new track vs existing track. + private insertAtTarget({ + element, + target, + trackType, + }: { + element: CreateTimelineElement; + target: DropTarget; + trackType: TrackType; + }): void { + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + this.config.executeCommand( + new BatchCommand([ + addTrackCmd, + new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }), + ]), + ); + return; + } + + const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() }); + const track = tracks[target.trackIndex]; + if (!track) return; + this.config.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + } + + private executeAssetDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: TimelineDragData; + }): void { + switch (dragData.type) { + case "text": + this.executeTextDrop({ target, dragData }); + return; + case "graphic": + this.executeGraphicDrop({ target, dragData }); + return; + case "sticker": + this.executeStickerDrop({ target, dragData }); + return; + case "effect": + this.executeEffectDrop({ target, dragData }); + return; + case "media": + this.executeMediaDrop({ target, dragData }); + return; + } + } + + private executeTextDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildTextElement({ + raw: { name: dragData.name ?? "", content: dragData.content ?? "" }, + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType: "text" }); + } + + private executeStickerDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildStickerElement({ + stickerId: dragData.stickerId, + name: dragData.name, + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType: "graphic" }); + } + + private executeGraphicDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildGraphicElement({ + definitionId: dragData.definitionId, + name: dragData.name, + startTime: target.xPosition, + params: dragData.params, + }); + this.insertAtTarget({ element, target, trackType: "graphic" }); + } + + private executeMediaDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + if (target.targetElement) { + // Replace media source — not yet implemented + return; + } + + const mediaAsset = this.config + .getMediaAssets() + .find((asset) => asset.id === dragData.id); + if (!mediaAsset) return; + + const trackType: TrackType = + dragData.mediaType === "audio" ? "audio" : "video"; + const element = buildElementFromMedia({ + mediaId: mediaAsset.id, + mediaType: mediaAsset.type, + name: mediaAsset.name, + duration: toElementDurationTicks({ seconds: mediaAsset.duration }), + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType }); + } + + private executeEffectDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + if (target.targetElement) { + this.config.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + + const existingEffectTrack = orderedTracks({ + sceneTracks: this.config.getSceneTracks(), + }).find((track) => track.type === "effect"); + + if (existingEffectTrack) { + this.config.insertElement({ + placement: { mode: "explicit", trackId: existingEffectTrack.id }, + element, + }); + return; + } + + this.insertAtTarget({ element, target, trackType: "effect" }); + } + + private async executeFileDrop({ + files, + mouseX, + mouseY, + }: { + files: File[]; + mouseX: number; + mouseY: number; + }): Promise { + const projectId = this.config.getActiveProjectId(); + if (!projectId) return; + + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + + // Sequential on purpose: each iteration reads getSceneTracks() + // to decide placement (reuse empty main vs new track) and that + // decision depends on the effects of prior inserts. + for (const asset of processedAssets) { + const createdAsset = await this.config.addMediaAsset({ + projectId, + asset, + }); + if (!createdAsset) continue; + + const duration = toElementDurationTicks({ + seconds: createdAsset.duration, + }); + + const sceneTracks = this.config.getSceneTracks(); + const currentTime = this.config.getCurrentPlayheadTime(); + + const reuseMainTrackId = + createdAsset.type !== "audio" && + sceneTracks.overlay.length === 0 && + sceneTracks.audio.length === 0 && + sceneTracks.main.elements.length === 0 + ? sceneTracks.main.id + : null; + + if (reuseMainTrackId) { + this.config.insertElement({ + placement: { mode: "explicit", trackId: reuseMainTrackId }, + element: buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: currentTime, + }), + }); + continue; + } + + const dropTarget = computeDropTarget({ + elementType: createdAsset.type, + mouseX, + mouseY, + tracks: sceneTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + }); + + const trackType: TrackType = + createdAsset.type === "audio" ? "audio" : "video"; + this.insertAtTarget({ + element: buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: dropTarget.xPosition, + }), + target: dropTarget, + trackType, + }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + } +} diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts new file mode 100644 index 00000000..cc19daaa --- /dev/null +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -0,0 +1,691 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, + type GroupMoveResult, + type MoveGroup, +} from "@/timeline/group-move"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { roundToFrame, type FrameRate } from "opencut-wasm"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { generateUUID } from "@/utils/id"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { + DropTarget, + ElementRef, + ElementDragView, + SceneTracks, + TimelineElement, + TimelineTrack, +} from "@/timeline"; + +const MOUSE_BUTTON_RIGHT = 2; + +// --- Config --- + +export interface ViewportAdapter { + getZoomLevel: () => number; + getTracksScrollEl: () => HTMLDivElement | null; + getTracksContainerEl: () => HTMLDivElement | null; + getHeaderEl: () => HTMLElement | null; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getTracks: () => SceneTracks; + getActiveFps: () => FrameRate | null; +} + +export interface ElementSelectionApi { + getSelected: () => readonly ElementRef[]; + isSelected: (ref: ElementRef) => boolean; + select: (ref: ElementRef) => void; + handleClick: (args: ElementRef & { isMultiKey: boolean }) => void; + clearKeyframeSelection: () => void; +} + +export interface PlaybackReader { + getCurrentTime: () => number; +} + +export interface TimelineOps { + moveElements: (args: Pick) => void; +} + +export interface SnapConfig { + isEnabled: () => boolean; + onChange?: (snapPoint: SnapPoint | null) => void; +} + +export interface ElementInteractionDeps { + viewport: ViewportAdapter; + input: InputAdapter; + scene: SceneReader; + selection: ElementSelectionApi; + playback: PlaybackReader; + timeline: TimelineOps; + snap: SnapConfig; +} + +export interface ElementInteractionDepsRef { + readonly current: ElementInteractionDeps; +} + +// --- Session --- + +type Point = { readonly x: number; readonly y: number }; + +interface MousedownSnapshot { + readonly origin: Point; + readonly elementId: string; + readonly trackId: string; + readonly startElementTime: number; + readonly clickOffsetTime: number; + readonly selectedElements: readonly ElementRef[]; +} + +interface DragProgress { + moveGroup: MoveGroup; + // Pre-minted per member so the identity of any "new track" created by + // this drag stays stable across mousemove-driven drop-target recomputes. + // `resolveGroupMoveForDrop` runs every mousemove and emits a + // `createTracks[]` carrying these IDs; downstream consumers (snap + // indicator, drop-line, commit path) see the same entity every frame + // instead of a churning UUID. + reservedNewTrackIds: readonly string[]; + currentTime: number; + currentMouseX: number; + currentMouseY: number; + groupMoveResult: GroupMoveResult | null; + dropTarget: DropTarget | null; +} + +type Session = + | { kind: "idle" } + | { kind: "pending"; mousedown: MousedownSnapshot } + | { kind: "dragging"; mousedown: MousedownSnapshot; drag: DragProgress }; + +const IDLE_VIEW: ElementDragView = { kind: "idle" }; + +// --- Pure helpers --- + +function pixelToClickOffsetTime( + clientX: number, + elementRect: DOMRect, + zoomLevel: number, +): number { + const clickOffsetX = clientX - elementRect.left; + const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); + return Math.round(seconds * TICKS_PER_SECOND); +} + +function verticalDirection( + startMouseY: number, + currentMouseY: number, +): "up" | "down" | null { + if (currentMouseY < startMouseY) return "up"; + if (currentMouseY > startMouseY) return "down"; + return null; +} + +function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { + return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; +} + +function movedPastDragThreshold(current: Point, origin: Point): boolean { + return ( + Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX || + Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX + ); +} + +function frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel, + clickOffsetTime, + fps, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + clickOffsetTime: number; + fps: FrameRate; +}): number { + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + }); + const adjusted = Math.max(0, mouseTime - clickOffsetTime); + return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted; +} + +function resolveDropTarget({ + clientX, + clientY, + elementId, + trackId, + tracks, + viewport, + zoomLevel, + snappedTime, + verticalDragDirection, +}: { + clientX: number; + clientY: number; + elementId: string; + trackId: string; + tracks: SceneTracks; + viewport: ViewportAdapter; + zoomLevel: number; + snappedTime: number; + verticalDragDirection: "up" | "down" | null; +}): DropTarget | null { + const containerRect = viewport + .getTracksContainerEl() + ?.getBoundingClientRect(); + const scrollContainer = viewport.getTracksScrollEl(); + if (!containerRect || !scrollContainer) return null; + + const sourceTrack = orderedTracks(tracks).find(({ id }) => id === trackId); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === elementId, + ); + if (!movingElement) return null; + + const scrollRect = scrollContainer.getBoundingClientRect(); + const headerHeight = + viewport.getHeaderEl()?.getBoundingClientRect().height ?? 0; + + return computeDropTarget({ + elementType: movingElement.type, + mouseX: clientX - scrollRect.left + scrollContainer.scrollLeft, + mouseY: clientY - scrollRect.top + scrollContainer.scrollTop - headerHeight, + tracks, + playheadTime: snappedTime, + isExternalDrop: false, + elementDuration: movingElement.duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel, + startTimeOverride: snappedTime, + excludeElementId: movingElement.id, + verticalDragDirection, + }); +} + +function resolveGroupMoveForDrop({ + group, + tracks, + anchorStartTime, + dropTarget, + reservedNewTrackIds, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + dropTarget: DropTarget; + reservedNewTrackIds: readonly string[]; +}): GroupMoveResult | null { + const newTracksFallback = () => + resolveGroupMove({ + group, + tracks, + anchorStartTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: [...reservedNewTrackIds], + }, + }); + + if (dropTarget.isNewTrack) return newTracksFallback(); + + const targetTrack = orderedTracks(tracks)[dropTarget.trackIndex]; + if (!targetTrack) return null; + + return ( + resolveGroupMove({ + group, + tracks, + anchorStartTime, + target: { kind: "existingTrack", anchorTargetTrackId: targetTrack.id }, + }) ?? newTracksFallback() + ); +} + +// --- Controller --- + +export class ElementInteractionController { + private session: Session = { kind: "idle" }; + // True once the active gesture crossed the drag threshold. Read by + // onElementClick, which fires after mouseup — by which point the session + // has already returned to idle, so the "was this a drag?" answer must + // outlive the session. Reset on the next mousedown. + private lastGestureWasDrag = false; + + private readonly subscribers = new Set<() => void>(); + private readonly depsRef: ElementInteractionDepsRef; + + constructor(args: { depsRef: ElementInteractionDepsRef }) { + this.depsRef = args.depsRef; + } + + private get deps(): ElementInteractionDeps { + return this.depsRef.current; + } + + get view(): ElementDragView { + if (this.session.kind !== "dragging") return IDLE_VIEW; + const { mousedown, drag } = this.session; + const memberTimeOffsets = new Map(); + for (const member of drag.moveGroup.members) { + memberTimeOffsets.set(member.elementId, member.timeOffset); + } + return { + kind: "dragging", + anchorElementId: mousedown.elementId, + trackId: mousedown.trackId, + memberTimeOffsets, + startMouseX: mousedown.origin.x, + startMouseY: mousedown.origin.y, + startElementTime: mousedown.startElementTime, + clickOffsetTime: mousedown.clickOffsetTime, + currentTime: drag.currentTime, + currentMouseX: drag.currentMouseX, + currentMouseY: drag.currentMouseY, + dropTarget: drag.dropTarget, + }; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel = (): void => { + this.lastGestureWasDrag = false; + this.finishSession(); + }; + + destroy(): void { + this.cancel(); + this.subscribers.clear(); + } + + onElementMouseDown = ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }): void => { + // Right-click must not stopPropagation — ContextMenu needs the bubble. + if (event.button === MOUSE_BUTTON_RIGHT) { + const ref = { trackId: track.id, elementId: element.id }; + if (!this.deps.selection.isSelected(ref)) { + this.deps.selection.handleClick({ ...ref, isMultiKey: false }); + } + return; + } + + event.stopPropagation(); + this.lastGestureWasDrag = false; + + const ref = { trackId: track.id, elementId: element.id }; + + if (event.metaKey || event.ctrlKey || event.shiftKey) { + this.deps.selection.handleClick({ ...ref, isMultiKey: true }); + } + + const selectedElements = this.deps.selection.isSelected(ref) + ? this.deps.selection.getSelected() + : [ref]; + + this.session = { + kind: "pending", + mousedown: { + origin: { x: event.clientX, y: event.clientY }, + elementId: element.id, + trackId: track.id, + startElementTime: element.startTime, + clickOffsetTime: pixelToClickOffsetTime( + event.clientX, + event.currentTarget.getBoundingClientRect(), + this.deps.viewport.getZoomLevel(), + ), + selectedElements, + }, + }; + this.activate(); + this.notify(); + }; + + onElementClick = ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }): void => { + event.stopPropagation(); + + if (this.lastGestureWasDrag) { + this.lastGestureWasDrag = false; + return; + } + + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + const ref = { trackId: track.id, elementId: element.id }; + if ( + !this.deps.selection.isSelected(ref) || + this.deps.selection.getSelected().length > 1 + ) { + this.deps.selection.select(ref); + return; + } + + this.deps.selection.clearKeyframeSelection(); + }; + + private activate(): void { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private finishSession(): void { + this.session = { kind: "idle" }; + this.deactivate(); + this.deps.snap.onChange?.(null); + this.notify(); + } + + private snapResult( + frameSnappedTime: number, + group: MoveGroup, + ): { snappedTime: number; snapPoint: SnapPoint | null } { + const { snap, input, scene, viewport, playback } = this.deps; + + if (!snap.isEnabled() || input.isShiftHeld()) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + const result = snapGroupEdges({ + group, + anchorStartTime: frameSnappedTime, + tracks: scene.getTracks(), + playheadTime: playback.getCurrentTime(), + zoomLevel: viewport.getZoomLevel(), + }); + + return { + snappedTime: result.snappedAnchorStartTime, + snapPoint: result.snapPoint, + }; + } + + private updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }: { + clientX: number; + clientY: number; + mousedown: MousedownSnapshot; + drag: DragProgress; + snappedTime: number; + }): void { + const { scene, viewport } = this.deps; + const tracks = scene.getTracks(); + const zoomLevel = viewport.getZoomLevel(); + + const anchorDropTarget = resolveDropTarget({ + clientX, + clientY, + elementId: mousedown.elementId, + trackId: mousedown.trackId, + tracks, + viewport, + zoomLevel, + snappedTime, + verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + }); + + const nextGroupMoveResult = anchorDropTarget + ? resolveGroupMoveForDrop({ + group: drag.moveGroup, + tracks, + anchorStartTime: snappedTime, + dropTarget: anchorDropTarget, + reservedNewTrackIds: drag.reservedNewTrackIds, + }) + : null; + + drag.groupMoveResult = nextGroupMoveResult; + drag.dropTarget = + anchorDropTarget && (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { ...anchorDropTarget, isNewTrack: true } + : null; + } + + private handleMouseMove = ({ clientX, clientY }: MouseEvent): void => { + const scrollContainer = this.deps.viewport.getTracksScrollEl(); + if (!scrollContainer) return; + + if (this.session.kind === "pending") { + this.beginDragFromPending({ + mousedown: this.session.mousedown, + clientX, + clientY, + scrollContainer, + }); + return; + } + + if (this.session.kind === "dragging") { + this.updateActiveDrag({ + mousedown: this.session.mousedown, + drag: this.session.drag, + clientX, + clientY, + scrollContainer, + }); + } + }; + + private beginDragFromPending({ + mousedown, + clientX, + clientY, + scrollContainer, + }: { + mousedown: MousedownSnapshot; + clientX: number; + clientY: number; + scrollContainer: HTMLDivElement; + }): void { + if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + return; + } + + const fps = this.deps.scene.getActiveFps(); + if (!fps) return; + + const moveGroup = buildMoveGroup({ + anchorRef: { + trackId: mousedown.trackId, + elementId: mousedown.elementId, + }, + selectedElements: [...mousedown.selectedElements], + tracks: this.deps.scene.getTracks(), + }); + if (!moveGroup) return; + + const zoomLevel = this.deps.viewport.getZoomLevel(); + const frameSnappedTime = frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel, + clickOffsetTime: mousedown.clickOffsetTime, + fps, + }); + const { snappedTime, snapPoint } = this.snapResult( + frameSnappedTime, + moveGroup, + ); + + // Ensure the anchor is selected before we render the drag — covers the + // case where the selection store hasn't committed the mousedown-time + // selection click yet. + const anchorRef = { + trackId: mousedown.trackId, + elementId: mousedown.elementId, + }; + if (!this.deps.selection.isSelected(anchorRef)) { + this.deps.selection.select(anchorRef); + } + + const drag: DragProgress = { + moveGroup, + reservedNewTrackIds: moveGroup.members.map(() => generateUUID()), + currentTime: snappedTime, + currentMouseX: clientX, + currentMouseY: clientY, + groupMoveResult: null, + dropTarget: null, + }; + + this.session = { kind: "dragging", mousedown, drag }; + this.lastGestureWasDrag = true; + + this.updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }); + + this.deps.snap.onChange?.(snapPoint); + this.notify(); + } + + private updateActiveDrag({ + mousedown, + drag, + clientX, + clientY, + scrollContainer, + }: { + mousedown: MousedownSnapshot; + drag: DragProgress; + clientX: number; + clientY: number; + scrollContainer: HTMLDivElement; + }): void { + const fps = this.deps.scene.getActiveFps(); + if (!fps) return; + + const frameSnappedTime = frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel: this.deps.viewport.getZoomLevel(), + clickOffsetTime: mousedown.clickOffsetTime, + fps, + }); + const { snappedTime, snapPoint } = this.snapResult( + frameSnappedTime, + drag.moveGroup, + ); + + drag.currentTime = snappedTime; + drag.currentMouseX = clientX; + drag.currentMouseY = clientY; + + this.updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }); + + this.deps.snap.onChange?.(snapPoint); + this.notify(); + } + + private handleMouseUp = ({ clientX, clientY }: MouseEvent): void => { + if (this.session.kind === "pending") { + this.finishSession(); + return; + } + + if (this.session.kind !== "dragging") return; + + const { mousedown, drag } = this.session; + + // If the drag returned within the click threshold of its origin, treat + // this as a cancel rather than a commit — the user dragged then put the + // element back. + if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + this.lastGestureWasDrag = false; + this.finishSession(); + return; + } + + const { moveGroup, groupMoveResult } = drag; + if (!groupMoveResult) { + this.finishSession(); + return; + } + + const didMove = groupMoveResult.moves.some((move) => { + const member = moveGroup.members.find( + (m) => m.elementId === move.elementId, + ); + const originalStartTime = + mousedown.startElementTime + (member?.timeOffset ?? 0); + return ( + member?.trackId !== move.targetTrackId || + originalStartTime !== move.newStartTime + ); + }); + + if (didMove || groupMoveResult.createTracks.length > 0) { + this.deps.timeline.moveElements({ + moves: groupMoveResult.moves, + createTracks: groupMoveResult.createTracks, + }); + } + + this.finishSession(); + }; +} diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts new file mode 100644 index 00000000..51c60dee --- /dev/null +++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts @@ -0,0 +1,342 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { getKeyframeById } from "@/animation"; +import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/commands"; +import type { SelectedKeyframeRef } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import type { Command } from "@/commands/base-command"; + +// --- Session --- + +interface PendingSession { + kind: "pending"; + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +interface ActiveSession { + kind: "active"; + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; + deltaTicks: number; +} + +type Session = { kind: "idle" } | PendingSession | ActiveSession; + +// --- Public state --- + +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set; + deltaTicks: number; +} + +const IDLE_DRAG_STATE: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTicks: 0, +}; + +// --- Config --- + +export interface KeyframeDragConfig { + zoomLevel: number; + getFps: () => FrameRate | null; + element: TimelineElement; + displayedStartTime: number; + selectedKeyframes: SelectedKeyframeRef[]; + isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean; + setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void; + toggleKeyframeSelection: (args: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => void; + selectKeyframeRange: (args: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => void; + executeCommand: (command: Command) => void; + seek: (args: { time: number }) => void; + getTotalDuration: () => number; +} + +export interface KeyframeDragConfigRef { + readonly current: KeyframeDragConfig; +} + +// --- Controller --- + +export class KeyframeDragController { + private session: Session = { kind: "idle" }; + // Persists through mouseup so the click handler can detect drag vs click + private mouseDownX: number | null = null; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: KeyframeDragConfigRef; + + constructor(deps: { configRef: KeyframeDragConfigRef }) { + this.configRef = deps.configRef; + this.onKeyframeMouseDown = this.onKeyframeMouseDown.bind(this); + this.onKeyframeClick = this.onKeyframeClick.bind(this); + this.getVisualOffsetPx = this.getVisualOffsetPx.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): KeyframeDragConfig { + return this.configRef.current; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + get keyframeDragState(): KeyframeDragState { + if (this.session.kind !== "active") return IDLE_DRAG_STATE; + return { + isDragging: true, + draggingKeyframeIds: new Set( + this.session.keyframeRefs.map((kf) => kf.keyframeId), + ), + deltaTicks: this.session.deltaTicks, + }; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel(): void { + this.mouseDownX = null; + this.finishSession(); + } + + destroy(): void { + this.deactivate(); + this.subscribers.clear(); + } + + onKeyframeMouseDown({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }): void { + event.preventDefault(); + event.stopPropagation(); + + this.mouseDownX = event.clientX; + + const anySelected = keyframes.some((kf) => + this.config.isKeyframeSelected({ keyframe: kf }), + ); + const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; + + if (!anySelected && !isModifierKey) { + this.config.setKeyframeSelection({ keyframes }); + } + + this.session = { + kind: "pending", + keyframeRefs: anySelected ? this.config.selectedKeyframes : keyframes, + startMouseX: event.clientX, + }; + this.activate(); + this.notify(); + } + + onKeyframeClick({ + event, + keyframes, + orderedKeyframes, + indicatorTime, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }): void { + event.stopPropagation(); + + const wasDrag = + this.mouseDownX !== null && + Math.abs(event.clientX - this.mouseDownX) > TIMELINE_DRAG_THRESHOLD_PX; + this.mouseDownX = null; + + if (wasDrag) return; + + const { displayedStartTime, getFps, getTotalDuration, seek } = this.config; + const fps = getFps(); + const seekTime = + fps != null + ? (snappedSeekTime({ + time: displayedStartTime + indicatorTime, + duration: getTotalDuration(), + rate: fps, + }) ?? displayedStartTime + indicatorTime) + : displayedStartTime + indicatorTime; + seek({ time: seekTime }); + + if (event.shiftKey) { + this.config.selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + this.config.toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + } + + getVisualOffsetPx({ + indicatorTime, + indicatorOffsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }): number { + if (!isBeingDragged || this.session.kind !== "active") + return indicatorOffsetPx; + const clampedTime = Math.max( + 0, + Math.min( + this.config.element.duration, + indicatorTime + this.session.deltaTicks, + ), + ); + return ( + timelineTimeToSnappedPixels({ + time: displayedStartTime + clampedTime, + zoomLevel: this.config.zoomLevel, + }) - elementLeft + ); + } + + private activate(): void { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private finishSession(): void { + this.session = { kind: "idle" }; + this.deactivate(); + this.notify(); + } + + private commitDrag({ + keyframeRefs, + deltaTicks, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTicks: number; + }): void { + const { element } = this.config; + const commands: Command[] = keyframeRefs.flatMap((ref) => { + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: ref.propertyPath, + keyframeId: ref.keyframeId, + }); + if (!keyframe) return []; + return [ + new RetimeKeyframeCommand({ + trackId: ref.trackId, + elementId: ref.elementId, + propertyPath: ref.propertyPath, + keyframeId: ref.keyframeId, + nextTime: Math.max( + 0, + Math.min(element.duration, keyframe.time + deltaTicks), + ), + }), + ]; + }); + + const [first, ...rest] = commands; + if (!first) return; + if (rest.length === 0) { + this.config.executeCommand(first); + } else { + this.config.executeCommand(new BatchCommand([first, ...rest])); + } + } + + private handleMouseMove({ clientX }: MouseEvent): void { + if (this.session.kind === "pending") { + const deltaX = Math.abs(clientX - this.session.startMouseX); + if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; + + this.session = { + kind: "active", + keyframeRefs: this.session.keyframeRefs, + startMouseX: this.session.startMouseX, + deltaTicks: 0, + }; + this.notify(); + return; + } + + if (this.session.kind !== "active") return; + + const fps = this.config.getFps(); + if (!fps) return; + + const pixelsPerSecond = + BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel; + const rawDeltaTicks = Math.round( + ((clientX - this.session.startMouseX) / pixelsPerSecond) * + TICKS_PER_SECOND, + ); + this.session.deltaTicks = + roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks; + this.notify(); + } + + private handleMouseUp(): void { + if (this.session.kind === "pending") { + this.finishSession(); + return; + } + + if (this.session.kind !== "active") return; + + const { selectedKeyframes, element } = this.config; + const { keyframeRefs, deltaTicks } = this.session; + const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId)); + const draggingRefs = selectedKeyframes.filter( + (kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId), + ); + + if (draggingRefs.length > 0 && deltaTicks !== 0) { + this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks }); + } + + this.finishSession(); + } +} diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts new file mode 100644 index 00000000..5260c046 --- /dev/null +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -0,0 +1,314 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { snappedSeekTime, type FrameRate } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, +} from "@/timeline/snapping"; +import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + getCenteredLineLeft, + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import type { Bookmark, SceneTracks } from "@/timeline"; + +// --- Session --- + +interface ScrubSession { + kind: "scrubbing"; + /** True when scrub started from a ruler click (not the playhead handle). */ + didStartFromRuler: boolean; + /** True once the mouse has moved during a ruler drag. */ + hasMoved: boolean; + /** Most recent frame-snapped time set by scrub(). */ + currentTime: number | null; +} + +type Session = { kind: "idle" } | ScrubSession; + +// --- Config --- + +export interface PlayheadConfig { + zoomLevel: number; + duration: number; + getActiveProjectFps: () => FrameRate | null; + isShiftHeld: () => boolean; + getIsPlaying: () => boolean; + getRulerEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getPlayheadEl: () => HTMLDivElement | null; + getSceneTracks: () => SceneTracks; + getSceneBookmarks: () => Bookmark[]; + seek: (time: number) => void; + setScrubbing: (isScrubbing: boolean) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: number; + }) => void; +} + +export interface PlayheadConfigRef { + readonly current: PlayheadConfig; +} + +// --- Pure helpers (px → logical) --- + +function pixelToTime({ + clientX, + rulerEl, + zoomLevel, + duration, +}: { + clientX: number; + rulerEl: HTMLDivElement; + zoomLevel: number; + duration: number; +}): number { + const rulerRect = rulerEl.getBoundingClientRect(); + const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel }); + const clampedX = Math.max( + 0, + Math.min(contentWidth, clientX - rulerRect.left), + ); + const seconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + return Math.round(seconds * TICKS_PER_SECOND); +} + +// --- Controller --- + +export class PlayheadController { + private lastMouseClientX = 0; + + private session: Session = { kind: "idle" }; + private readonly configRef: PlayheadConfigRef; + + constructor(deps: { configRef: PlayheadConfigRef }) { + this.configRef = deps.configRef; + this.onPlayheadMouseDown = this.onPlayheadMouseDown.bind(this); + this.onRulerMouseDown = this.onRulerMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): PlayheadConfig { + return this.configRef.current; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + getLastMouseClientX(): number { + return this.lastMouseClientX; + } + + destroy(): void { + this.deactivate(); + } + + // --- Public event handlers (bound, stable references) --- + + onPlayheadMouseDown(event: ReactMouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.session = { + kind: "scrubbing", + didStartFromRuler: false, + hasMoved: false, + currentTime: null, + }; + this.config.setScrubbing(true); + this.scrub({ event, isElementSnappingEnabled: true }); + this.activate(); + } + + onRulerMouseDown(event: ReactMouseEvent): void { + if (event.button !== 0) return; + if (this.config.getPlayheadEl()?.contains(event.target as Node)) return; + + event.preventDefault(); + this.session = { + kind: "scrubbing", + didStartFromRuler: true, + hasMoved: false, + currentTime: null, + }; + this.config.setScrubbing(true); + // No element-edge snapping on initial ruler click — avoids a jarring jump. + this.scrub({ event, isElementSnappingEnabled: false }); + this.activate(); + } + + // --- Public non-session methods --- + + /** + * Imperatively updates the playhead DOM element's `left` style. + * Called on scroll and playback events to avoid React re-renders + * during animation frame updates. + */ + updatePlayheadLeft(time: number): void { + const playheadEl = this.config.getPlayheadEl(); + if (!playheadEl) return; + + const centerPixel = timelineTimeToSnappedPixels({ + time, + zoomLevel: this.config.zoomLevel, + }); + const scrollLeft = this.config.getRulerScrollEl()?.scrollLeft ?? 0; + playheadEl.style.left = `${getCenteredLineLeft({ centerPixel }) - scrollLeft}px`; + } + + /** + * Updates the playhead position and auto-scrolls to keep the playhead + * visible during playback. + */ + handlePlaybackUpdate(time: number): void { + this.updatePlayheadLeft(time); + + // Auto-scroll only during playback, not while scrubbing. + if (!this.config.getIsPlaying() || this.session.kind === "scrubbing") + return; + + const rulerViewport = this.config.getRulerScrollEl(); + const tracksViewport = this.config.getTracksScrollEl(); + if (!rulerViewport || !tracksViewport) return; + + const playheadPixels = timelineTimeToPixels({ + time, + zoomLevel: this.config.zoomLevel, + }); + const viewportWidth = rulerViewport.clientWidth; + const isOutOfView = + playheadPixels < rulerViewport.scrollLeft || + playheadPixels > rulerViewport.scrollLeft + viewportWidth; + + if (isOutOfView) { + const desiredScroll = Math.max( + 0, + Math.min( + rulerViewport.scrollWidth - viewportWidth, + playheadPixels - viewportWidth / 2, + ), + ); + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + } + + // --- Private --- + + private activate(): void { + window.addEventListener("mousemove", this.handleMouseMove); + window.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + window.removeEventListener("mousemove", this.handleMouseMove); + window.removeEventListener("mouseup", this.handleMouseUp); + } + + /** + * Converts pointer position to a frame-snapped timeline time and seeks. + * `isElementSnappingEnabled` controls element-edge snapping; frame-level snapping + * is always applied. + */ + private scrub({ + event, + isElementSnappingEnabled, + }: { + event: MouseEvent | ReactMouseEvent; + isElementSnappingEnabled: boolean; + }): void { + const ruler = this.config.getRulerEl(); + if (!ruler) return; + + const fps = this.config.getActiveProjectFps(); + if (!fps) return; + + const { zoomLevel, duration } = this.config; + const rawTime = pixelToTime({ + clientX: event.clientX, + rulerEl: ruler, + zoomLevel, + duration, + }); + const frameTime = + snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime; + + const time = (() => { + if (!isElementSnappingEnabled || this.config.isShiftHeld()) + return frameTime; + + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => + getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }), + () => + getBookmarkSnapPoints({ + bookmarks: this.config.getSceneBookmarks(), + }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks: this.config.getSceneTracks(), + }), + ], + }); + const result = resolveTimelineSnap({ + targetTime: frameTime, + snapPoints, + maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), + }); + return result.snapPoint ? result.snappedTime : frameTime; + })(); + + if (this.session.kind === "scrubbing") { + this.session.currentTime = time; + } + this.config.seek(time); + this.lastMouseClientX = event.clientX; + } + + private handleMouseMove(event: MouseEvent): void { + if (this.session.kind !== "scrubbing") return; + this.scrub({ event, isElementSnappingEnabled: true }); + if (this.session.didStartFromRuler) { + this.session.hasMoved = true; + } + } + + private handleMouseUp(event: MouseEvent): void { + if (this.session.kind !== "scrubbing") return; + + const session = this.session; + this.config.setScrubbing(false); + + if (session.currentTime !== null) { + this.config.seek(session.currentTime); + this.config.setTimelineViewState({ + zoomLevel: this.config.zoomLevel, + scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0, + playheadTime: session.currentTime, + }); + } + + // Ruler click without drag: snap to clicked position on mouseup. + if (session.didStartFromRuler && !session.hasMoved) { + this.scrub({ event, isElementSnappingEnabled: false }); + } + + this.session = { kind: "idle" }; + this.deactivate(); + } +} diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts new file mode 100644 index 00000000..8677dd15 --- /dev/null +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -0,0 +1,329 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { + computeGroupResize, + type GroupResizeMember, + type GroupResizeResult, + type GroupResizeUpdate, + type ResizeSide, +} from "@/timeline/group-resize"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, + type SnapPoint, +} from "@/timeline/snapping"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + isRetimableElement, + type SceneTracks, + type TimelineElement, + type TimelineTrack, +} from "@/timeline"; +import type { ElementRef } from "@/timeline/types"; +import type { FrameRate } from "opencut-wasm"; + +// --- Session --- + +interface ResizeSession { + kind: "active"; + side: ResizeSide; + startX: number; + fps: FrameRate; + members: GroupResizeMember[]; + result: GroupResizeResult | null; +} + +type Session = { kind: "idle" } | ResizeSession; + +// --- Config --- + +export interface ResizeConfig { + zoomLevel: number; + snappingEnabled: boolean; + isShiftHeld: () => boolean; + getSceneTracks: () => SceneTracks; + getCurrentPlayheadTime: () => number; + getActiveProjectFps: () => FrameRate | null; + selectedElements: ElementRef[]; + discardPreview: () => void; + previewElements: (updates: GroupResizeUpdate[]) => void; + commitElements: (updates: GroupResizeUpdate[]) => void; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +export interface ResizeConfigRef { + readonly current: ResizeConfig; +} + +// --- Pure helpers --- + +export function buildResizeMembers({ + tracks, + selectedElements, +}: { + tracks: SceneTracks; + selectedElements: ElementRef[]; +}): GroupResizeMember[] { + const selectedElementIds = new Set( + selectedElements.map((el) => el.elementId), + ); + const trackMap = new Map( + [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ + track.id, + track, + ]), + ); + + return selectedElements.flatMap(({ trackId, elementId }) => { + const track = trackMap.get(trackId); + const element = track?.elements.find((el) => el.id === elementId); + if (!track || !element) return []; + + const otherElements = track.elements.filter( + (el) => !selectedElementIds.has(el.id), + ); + const leftNeighborBound = otherElements + .filter((el) => el.startTime + el.duration <= element.startTime) + .reduce( + (bound, el) => Math.max(bound, el.startTime + el.duration), + -Infinity, + ); + const rightNeighborBound = otherElements + .filter((el) => el.startTime >= element.startTime + element.duration) + .reduce((bound, el) => Math.min(bound, el.startTime), Infinity); + + return [ + { + trackId, + elementId, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + sourceDuration: element.sourceDuration, + retime: isRetimableElement(element) ? element.retime : undefined, + leftNeighborBound, + rightNeighborBound, + }, + ]; + }); +} + +function hasResizeChanges({ + members, + result, +}: { + members: GroupResizeMember[]; + result: GroupResizeResult; +}): boolean { + return result.updates.some((update) => { + const member = members.find((m) => m.elementId === update.elementId); + return ( + member?.trimStart !== update.patch.trimStart || + member?.trimEnd !== update.patch.trimEnd || + member?.startTime !== update.patch.startTime || + member?.duration !== update.patch.duration + ); + }); +} + +// --- Controller --- + +export class ResizeController { + private session: Session = { kind: "idle" }; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: ResizeConfigRef; + + constructor(deps: { configRef: ResizeConfigRef }) { + this.configRef = deps.configRef; + this.onResizeStart = this.onResizeStart.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): ResizeConfig { + return this.configRef.current; + } + + get isResizing(): boolean { + return this.session.kind === "active"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel(): void { + this.config.discardPreview(); + this.finishSession(); + } + + destroy(): void { + this.deactivate(); + this.subscribers.clear(); + } + + onResizeStart({ + event, + element, + track, + side, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + side: ResizeSide; + }): void { + event.stopPropagation(); + event.preventDefault(); + + // UI should prevent this, but be explicit: a new resize start + // means the previous one is abandoned, not silently replaced. + if (this.session.kind === "active") this.cancel(); + + const fps = this.config.getActiveProjectFps(); + if (!fps) return; + + const ref = { trackId: track.id, elementId: element.id }; + const activeSelection = this.config.selectedElements.some( + (el) => el.trackId === track.id && el.elementId === element.id, + ) + ? this.config.selectedElements + : [ref]; + + const members = buildResizeMembers({ + tracks: this.config.getSceneTracks(), + selectedElements: activeSelection, + }); + if (members.length === 0) return; + + this.config.discardPreview(); + + this.session = { + kind: "active", + side, + startX: event.clientX, + fps, + members, + result: null, + }; + this.activate(); + this.notify(); + } + + private activate(): void { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private finishSession(): void { + this.session = { kind: "idle" }; + this.deactivate(); + this.config.onSnapPointChange?.(null); + this.notify(); + } + + private snappedDelta(session: ResizeSession, rawDeltaTicks: number): number { + const { snappingEnabled, isShiftHeld, zoomLevel } = this.config; + + if (!snappingEnabled || isShiftHeld()) { + this.config.onSnapPointChange?.(null); + return rawDeltaTicks; + } + + const tracks = this.config.getSceneTracks(); + const playheadTime = this.config.getCurrentPlayheadTime(); + const excludeElementIds = new Set(session.members.map((m) => m.elementId)); + + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), + () => getPlayheadSnapPoints({ playheadTime }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, + }), + ], + }); + const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); + + let closestSnapPoint: SnapPoint | null = null; + let closestSnapDistance = Infinity; + let deltaTicks = rawDeltaTicks; + + for (const member of session.members) { + const baseEdgeTime = + session.side === "left" + ? member.startTime + : member.startTime + member.duration; + const snapResult = resolveTimelineSnap({ + targetTime: baseEdgeTime + rawDeltaTicks, + snapPoints, + maxSnapDistance, + }); + if ( + snapResult.snapPoint && + snapResult.snapDistance < closestSnapDistance + ) { + closestSnapDistance = snapResult.snapDistance; + closestSnapPoint = snapResult.snapPoint; + deltaTicks = snapResult.snappedTime - baseEdgeTime; + } + } + + this.config.onSnapPointChange?.(closestSnapPoint); + return deltaTicks; + } + + private handleMouseMove({ clientX }: MouseEvent): void { + if (this.session.kind !== "active") return; + const session = this.session; + + const rawDeltaTicks = Math.round( + ((clientX - session.startX) / + (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) * + TICKS_PER_SECOND, + ); + const deltaTicks = this.snappedDelta(session, rawDeltaTicks); + const result = computeGroupResize({ + members: session.members, + side: session.side, + deltaTicks, + fps: session.fps, + }); + + session.result = result; + this.config.previewElements(result.updates); + } + + private handleMouseUp(): void { + if (this.session.kind !== "active") return; + const session = this.session; + + this.config.discardPreview(); + + if ( + session.result && + hasResizeChanges({ members: session.members, result: session.result }) + ) { + this.config.commitElements(session.result.updates); + } + + this.finishSession(); + } +} diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts new file mode 100644 index 00000000..f32c855e --- /dev/null +++ b/apps/web/src/timeline/controllers/seek-controller.ts @@ -0,0 +1,210 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { snappedSeekTime, type FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; + +type SeekSource = "ruler" | "tracks"; + +interface PendingSeekSession { + kind: "pending"; + source: SeekSource; + downX: number; + downY: number; + downTime: number; +} + +type Session = { kind: "idle" } | PendingSeekSession; + +export interface SeekConfig { + zoomLevel: number; + duration: number; + isSelecting: boolean; + getPlayheadEl: () => HTMLDivElement | null; + getTrackLabelsEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + clearSelectedElements: () => void; + seek: (time: number) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: number; + }) => void; +} + +export interface SeekConfigRef { + readonly current: SeekConfig; +} + +function pixelToTime({ + clientX, + scrollContainer, + zoomLevel, + duration, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + duration: number; +}): number { + const rect = scrollContainer.getBoundingClientRect(); + const mouseX = clientX - rect.left; + const scrollLeft = scrollContainer.scrollLeft; + + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + + return Math.round(rawTimeSeconds * TICKS_PER_SECOND); +} + +function isClickGesture({ + event, + session, +}: { + event: ReactMouseEvent; + session: PendingSeekSession; +}): boolean { + const deltaX = Math.abs(event.clientX - session.downX); + const deltaY = Math.abs(event.clientY - session.downY); + const deltaTime = event.timeStamp - session.downTime; + + return deltaX <= 5 && deltaY <= 5 && deltaTime <= 500; +} + +export class SeekController { + private session: Session = { kind: "idle" }; + private readonly configRef: SeekConfigRef; + + constructor(deps: { configRef: SeekConfigRef }) { + this.configRef = deps.configRef; + this.onTracksMouseDown = this.onTracksMouseDown.bind(this); + this.onRulerMouseDown = this.onRulerMouseDown.bind(this); + this.onTracksClick = this.onTracksClick.bind(this); + this.onRulerClick = this.onRulerClick.bind(this); + } + + private get config(): SeekConfig { + return this.configRef.current; + } + + destroy(): void { + this.session = { kind: "idle" }; + } + + onTracksMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "tracks" }); + } + + onRulerMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "ruler" }); + } + + onTracksClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "tracks" }); + } + + onRulerClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "ruler" }); + } + + private beginPendingSeek({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + if (event.button !== 0) return; + + this.session = { + kind: "pending", + source, + downX: event.clientX, + downY: event.clientY, + downTime: event.timeStamp, + }; + } + + private handleClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const shouldProcess = this.shouldProcessClick({ event, source }); + this.session = { kind: "idle" }; + + if (!shouldProcess) return; + + this.config.clearSelectedElements(); + this.seekFromEvent({ event, source }); + } + + private shouldProcessClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): boolean { + if (this.session.kind !== "pending") return false; + if (this.session.source !== source) return false; + if (!isClickGesture({ event, session: this.session })) return false; + if (this.config.isSelecting) return false; + + const target = event.target as HTMLElement; + if (this.config.getPlayheadEl()?.contains(target)) return false; + + if (this.config.getTrackLabelsEl()?.contains(target)) { + this.config.clearSelectedElements(); + return false; + } + + return true; + } + + private seekFromEvent({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const scrollContainer = + source === "ruler" + ? this.config.getRulerScrollEl() + : this.config.getTracksScrollEl(); + if (!scrollContainer) return; + + const rawTime = pixelToTime({ + clientX: event.clientX, + scrollContainer, + zoomLevel: this.config.zoomLevel, + duration: this.config.duration, + }); + + const fps = this.config.getActiveProjectFps(); + const time = + fps != null + ? (snappedSeekTime({ + time: rawTime, + duration: this.config.duration, + rate: fps, + }) ?? rawTime) + : rawTime; + + this.config.seek(time); + this.config.setTimelineViewState({ + zoomLevel: this.config.zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }); + } +} diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts new file mode 100644 index 00000000..8e0af4fe --- /dev/null +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -0,0 +1,285 @@ +import type { WheelEvent as ReactWheelEvent } from "react"; +import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { TIMELINE_ZOOM_MAX } from "@/timeline/scale"; +import { zoomToSlider } from "@/timeline/zoom-utils"; + +type ZoomUpdater = number | ((prev: number) => number); + +export interface ZoomConfig { + minZoom: number; + getContainerEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getCurrentPlayheadTime: () => number; + seek: (time: number) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: number; + }) => void; +} + +export interface ZoomConfigRef { + readonly current: ZoomConfig; +} + +function clampZoom(zoomLevel: number, minZoom: number): number { + return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); +} + +export class ZoomController { + private readonly configRef: ZoomConfigRef; + private readonly subscribers = new Set<() => void>(); + + private zoomLevelValue: number; + private hasInitialized = false; + private hasRestoredPlayhead = false; + private hasRestoredScroll = false; + private previousZoom: number; + private preZoomScrollLeft = 0; + private prePlayheadAnchorScrollLeft = 0; + private isInPlayheadAnchorMode = false; + private scrollSaveTimeout: ReturnType | null = null; + + constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) { + this.configRef = deps.configRef; + + const minZoom = this.config.minZoom; + this.zoomLevelValue = + deps.initialZoom !== undefined + ? clampZoom(deps.initialZoom, minZoom) + : minZoom; + this.previousZoom = this.zoomLevelValue; + this.hasInitialized = deps.initialZoom !== undefined; + + this.setZoomLevel = this.setZoomLevel.bind(this); + this.handleWheel = this.handleWheel.bind(this); + this.saveScrollPosition = this.saveScrollPosition.bind(this); + } + + private get config(): ZoomConfig { + return this.configRef.current; + } + + get zoomLevel(): number { + return this.zoomLevelValue; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + this.scrollSaveTimeout = null; + } + } + + setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void { + const scrollElement = this.config.getTracksScrollEl(); + if (scrollElement) { + this.preZoomScrollLeft = scrollElement.scrollLeft; + } + + const nextZoomRaw = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(this.zoomLevelValue) + : zoomLevelOrUpdater; + const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + if (nextZoom === this.zoomLevelValue) return; + + this.zoomLevelValue = nextZoom; + this.notify(); + } + + handleWheel(event: ReactWheelEvent): void { + const isZoomGesture = event.ctrlKey || event.metaKey; + const isHorizontalScrollGesture = + event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + + if (isHorizontalScrollGesture) { + return; + } + + if (isZoomGesture) { + const normalizedDelta = + event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; + const cappedDelta = + Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + this.setZoomLevel((prev) => prev * zoomFactor); + } + } + + reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + if (initialZoom !== undefined && !this.hasInitialized) { + this.hasInitialized = true; + this.setZoomLevel(clampZoom(initialZoom, minZoom)); + return; + } + + if (this.zoomLevelValue < minZoom) { + this.setZoomLevel(minZoom); + } + } + + applyZoomLayout(zoomLevel: number): void { + const previousZoom = this.previousZoom; + if (previousZoom === zoomLevel) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) { + this.previousZoom = zoomLevel; + return; + } + + const currentScrollLeft = this.preZoomScrollLeft; + const playheadTime = this.config.getCurrentPlayheadTime(); + const sliderPercent = zoomToSlider({ + zoomLevel, + minZoom: this.config.minZoom, + }); + const previousSliderPercent = zoomToSlider({ + zoomLevel: previousZoom, + minZoom: this.config.minZoom, + }); + const isCrossingThresholdUp = + previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + const isCrossingThresholdDown = + previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + + const syncScroll = (scrollLeft: number) => { + scrollElement.scrollLeft = scrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = scrollLeft; + } + }; + + const clampScrollLeft = (scrollLeft: number) => { + const maxScrollLeft = + scrollElement.scrollWidth - scrollElement.clientWidth; + return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); + }; + + if (isCrossingThresholdUp) { + this.prePlayheadAnchorScrollLeft = currentScrollLeft; + this.isInPlayheadAnchorMode = true; + } + + if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { + const playheadPixelsBefore = timelineTimeToPixels({ + time: playheadTime, + zoomLevel: previousZoom, + }); + const playheadPixelsAfter = timelineTimeToPixels({ + time: playheadTime, + zoomLevel, + }); + const viewportOffset = playheadPixelsBefore - currentScrollLeft; + const nextScrollLeft = playheadPixelsAfter - viewportOffset; + syncScroll(clampScrollLeft(nextScrollLeft)); + } else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) { + syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft)); + this.isInPlayheadAnchorMode = false; + } + + this.previousZoom = zoomLevel; + + this.config.setTimelineViewState({ + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime, + }); + } + + saveScrollPosition(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + } + + this.scrollSaveTimeout = setTimeout(() => { + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + this.config.setTimelineViewState({ + zoomLevel: this.zoomLevelValue, + scrollLeft: scrollElement.scrollLeft, + playheadTime: this.config.getCurrentPlayheadTime(), + }); + }, 300); + } + + restoreInitialScrollIfNeeded( + initialScrollLeft?: number, + ): (() => void) | undefined { + if (initialScrollLeft === undefined) return; + if (this.hasRestoredScroll) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + const restoreScroll = () => { + scrollElement.scrollLeft = initialScrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = initialScrollLeft; + } + this.hasRestoredScroll = true; + this.preZoomScrollLeft = initialScrollLeft; + }; + + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + return; + } + + const observer = new ResizeObserver(() => { + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + observer.disconnect(); + } + }); + observer.observe(scrollElement); + return () => observer.disconnect(); + } + + restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void { + if (initialPlayheadTime === undefined) return; + if (this.hasRestoredPlayhead) return; + + this.hasRestoredPlayhead = true; + this.config.seek(initialPlayheadTime); + } + + bindPreventBrowserZoom(): () => void { + const preventZoom = (event: WheelEvent) => { + const isZoomKeyPressed = event.ctrlKey || event.metaKey; + const container = this.config.getContainerEl(); + const isInContainer = container?.contains(event.target as Node) ?? false; + if (isZoomKeyPressed && isInContainer) { + event.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { + passive: false, + capture: true, + }); + + return () => { + document.removeEventListener("wheel", preventZoom, { capture: true }); + }; + } + + private notify(): void { + for (const fn of this.subscribers) { + fn(); + } + } +} diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index 2ac7f246..2dec3186 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,3 +1,20 @@ import { TICKS_PER_SECOND } from "@/wasm/ticks"; export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; + +/** + * Resolves the duration in ticks that a newly created timeline element + * should have, given an optional source duration in seconds. + * + * Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has + * no known duration (e.g. stickers, graphics, text, or media whose + * probe failed). + */ +export function toElementDurationTicks({ + seconds, +}: { + seconds: number | null | undefined; +}): number { + if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION; + return Math.round(seconds * TICKS_PER_SECOND); +} diff --git a/apps/web/src/timeline/drag-data.ts b/apps/web/src/timeline/drag-data.ts deleted file mode 100644 index 1fc9ce9a..00000000 --- a/apps/web/src/timeline/drag-data.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { TimelineDragData } from "@/timeline/drag"; - -const MIME_TYPE = "application/x-timeline-drag"; -let lastDragData: TimelineDragData | null = null; - -export function setDragData({ - dataTransfer, - dragData, -}: { - dataTransfer: DataTransfer; - dragData: TimelineDragData; -}): void { - dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData)); - dataTransfer.setData("text/plain", JSON.stringify(dragData)); - lastDragData = dragData; -} - -export function getDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): TimelineDragData | null { - const data = dataTransfer.getData(MIME_TYPE); - if (data) return JSON.parse(data) as TimelineDragData; - - const textData = dataTransfer.getData("text/plain"); - if (textData) { - try { - return JSON.parse(textData) as TimelineDragData; - } catch { - return lastDragData; - } - } - - return lastDragData; -} - -export function hasDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): boolean { - return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null; -} - -export function clearDragData(): void { - lastDragData = null; -} diff --git a/apps/web/src/timeline/drag-source.ts b/apps/web/src/timeline/drag-source.ts new file mode 100644 index 00000000..503c547b --- /dev/null +++ b/apps/web/src/timeline/drag-source.ts @@ -0,0 +1,40 @@ +import type { TimelineDragData } from "@/timeline/drag"; + +const TIMELINE_DRAG_MIME = "application/x-timeline-drag"; + +/** + * Owns the state of an in-progress timeline drag session. + * + * Exists because browsers restrict `DataTransfer.getData()` to the `drop` + * event for security — during `dragover`/`dragenter` only `types` is + * readable. The drop target needs the payload (element type, target + * element types, source duration) while the pointer is hovering, so we + * keep a live copy here and hand it out via {@link getActive}. + */ +export class TimelineDragSource { + private active: TimelineDragData | null = null; + + begin({ + dataTransfer, + dragData, + }: { + dataTransfer: DataTransfer; + dragData: TimelineDragData; + }): void { + dataTransfer.setData(TIMELINE_DRAG_MIME, JSON.stringify(dragData)); + dataTransfer.effectAllowed = "copy"; + this.active = dragData; + } + + end(): void { + this.active = null; + } + + getActive(): TimelineDragData | null { + return this.active; + } + + isActive(): boolean { + return this.active !== null; + } +} diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index dd542967..d22b6891 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -15,63 +15,63 @@ import type { export function computeGroupResize({ members, side, - deltaTime, + deltaTicks, fps, }: ComputeGroupResizeArgs): GroupResizeResult { const minDuration = Math.round( (TICKS_PER_SECOND * fps.denominator) / fps.numerator, ); - const minimumDeltaTime = Math.max( + const minimumDeltaTicks = Math.max( ...members.map((member) => - getMinimumAllowedDeltaTime({ + getMinimumAllowedDeltaTicks({ member, side, minDuration, }), ), ); - const maximumDeltaTime = Math.min( + const maximumDeltaTicks = Math.min( ...members.map((member) => - getMaximumAllowedDeltaTime({ + getMaximumAllowedDeltaTicks({ member, side, minDuration, }), ), ); - const clampedDeltaTime = - minimumDeltaTime > maximumDeltaTime - ? minimumDeltaTime - : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime)); + const clampedDeltaTicks = + minimumDeltaTicks > maximumDeltaTicks + ? minimumDeltaTicks + : Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks)); // Snap the drag delta to a frame exactly once, then derive every patch // field from that single snapped value. This keeps the invariant // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same // delta is added on one side of the element and removed from the other, - // so the rounding cancels by construction. Per-field rounding (the old - // approach) couldn't preserve this because the individual rounds don't - // compose when `sourceDuration` isn't frame-aligned. - const snappedDeltaTime = - roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime; + // so the rounding cancels by construction. Rounding each field + // independently would break this — the individual rounds don't compose + // when `sourceDuration` isn't frame-aligned. + const snappedDeltaTicks = + roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks; // Re-clamp after rounding. Bounds derived from other elements are // frame-aligned, so this is normally a no-op; at the source-extent limit // the bound may not be frame-aligned, and honouring the bound takes // precedence over frame alignment (you can't extend past real content). - const finalDeltaTime = - minimumDeltaTime > maximumDeltaTime - ? minimumDeltaTime + const finalDeltaTicks = + minimumDeltaTicks > maximumDeltaTicks + ? minimumDeltaTicks : Math.min( - maximumDeltaTime, - Math.max(minimumDeltaTime, snappedDeltaTime), + maximumDeltaTicks, + Math.max(minimumDeltaTicks, snappedDeltaTicks), ); return { - deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime, + deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks, updates: members.map((member) => buildResizeUpdate({ member, side, - deltaTime: finalDeltaTime, + deltaTicks: finalDeltaTicks, }), ), }; @@ -80,15 +80,15 @@ export function computeGroupResize({ function buildResizeUpdate({ member, side, - deltaTime, + deltaTicks, }: { member: GroupResizeMember; side: ResizeSide; - deltaTime: number; + deltaTicks: number; }): GroupResizeUpdate { const sourceDelta = getSourceDeltaForClipDelta({ member, - clipDelta: deltaTime, + clipDelta: deltaTicks, }); if (side === "left") { @@ -98,8 +98,8 @@ function buildResizeUpdate({ patch: { trimStart: Math.max(0, member.trimStart + sourceDelta), trimEnd: member.trimEnd, - startTime: member.startTime + deltaTime, - duration: member.duration - deltaTime, + startTime: member.startTime + deltaTicks, + duration: member.duration - deltaTicks, }, }; } @@ -111,12 +111,12 @@ function buildResizeUpdate({ trimStart: member.trimStart, trimEnd: Math.max(0, member.trimEnd - sourceDelta), startTime: member.startTime, - duration: member.duration + deltaTime, + duration: member.duration + deltaTicks, }, }; } -function getMinimumAllowedDeltaTime({ +function getMinimumAllowedDeltaTicks({ member, side, minDuration, @@ -148,7 +148,7 @@ function getMinimumAllowedDeltaTime({ return Math.max(leftNeighborFloor, -maximumSourceExtension); } -function getMaximumAllowedDeltaTime({ +function getMaximumAllowedDeltaTicks({ member, side, minDuration, diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts index b07e6fe6..511358ba 100644 --- a/apps/web/src/timeline/group-resize/types.ts +++ b/apps/web/src/timeline/group-resize/types.ts @@ -24,13 +24,13 @@ export interface GroupResizeUpdate extends ElementRef { } export interface GroupResizeResult { - deltaTime: number; + deltaTicks: number; updates: GroupResizeUpdate[]; } export interface ComputeGroupResizeArgs { members: GroupResizeMember[]; side: ResizeSide; - deltaTime: number; + deltaTicks: number; fps: FrameRate; } 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..26a21f85 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,42 +1,17 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, - type RefObject, -} from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; -import { - buildMoveGroup, - resolveGroupMove, - snapGroupEdges, - type GroupMoveResult, - type MoveGroup, -} from "@/timeline/group-move"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame } from "opencut-wasm"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; -import { generateUUID } from "@/utils/id"; -import type { SnapPoint } from "@/timeline/snapping"; import { registerCanceller } from "@/editor/cancel-interaction"; -import type { - DropTarget, - ElementRef, - ElementDragState, - SceneTracks, - TimelineElement, - TimelineTrack, -} from "@/timeline"; +import { + ElementInteractionController, + type ElementInteractionDeps, + type ElementInteractionDepsRef, +} from "@/timeline/controllers/element-interaction-controller"; +import type { SnapPoint } from "@/timeline/snapping"; interface UseElementInteractionProps { zoomLevel: number; - timelineRef: RefObject; tracksContainerRef: RefObject; tracksScrollRef: RefObject; headerRef?: RefObject; @@ -44,131 +19,8 @@ interface UseElementInteractionProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } -const MOUSE_BUTTON_RIGHT = 2; - -const initialDragState: ElementDragState = { - isDragging: false, - elementId: null, - dragElementIds: [], - dragTimeOffsets: {}, - trackId: null, - startMouseX: 0, - startMouseY: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - currentMouseY: 0, -}; - -interface PendingDragState { - elementId: string; - trackId: string; - selectedElements: ElementRef[]; - startMouseX: number; - startMouseY: number; - startElementTime: number; - clickOffsetTime: number; -} - -function getClickOffsetTime({ - clientX, - elementRect, - zoomLevel, -}: { - clientX: number; - elementRect: DOMRect; - zoomLevel: number; -}): number { - const clickOffsetX = clientX - elementRect.left; - const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); -} - -function getVerticalDragDirection({ - startMouseY, - currentMouseY, -}: { - startMouseY: number; - currentMouseY: number; -}): "up" | "down" | null { - if (currentMouseY < startMouseY) return "up"; - if (currentMouseY > startMouseY) return "down"; - return null; -} - -function getDragDropTarget({ - clientX, - clientY, - elementId, - trackId, - tracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, -}: { - clientX: number; - clientY: number; - elementId: string; - trackId: string; - tracks: SceneTracks; - tracksContainerRef: RefObject; - tracksScrollRef: RefObject; - headerRef?: RefObject; - zoomLevel: number; - snappedTime: number; - verticalDragDirection?: "up" | "down" | null; -}): DropTarget | null { - const containerRect = tracksContainerRef.current?.getBoundingClientRect(); - const scrollContainer = tracksScrollRef.current; - if (!containerRect || !scrollContainer) return null; - - const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( - ({ id }) => id === trackId, - ); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === elementId, - ); - if (!movingElement) return null; - - const elementDuration = movingElement.duration; - const scrollLeft = scrollContainer.scrollLeft; - const scrollTop = scrollContainer.scrollTop; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseX = clientX - scrollContainerRect.left + scrollLeft; - const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; - - return computeDropTarget({ - elementType: movingElement.type, - mouseX, - mouseY, - tracks, - playheadTime: snappedTime, - isExternalDrop: false, - elementDuration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - startTimeOverride: snappedTime, - excludeElementId: movingElement.id, - verticalDragDirection, - }); -} - -interface StartDragParams - extends Omit< - ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" - > { - initialCurrentTime: number; - initialCurrentMouseY: number; -} - export function useElementInteraction({ zoomLevel, - timelineRef, tracksContainerRef, tracksScrollRef, headerRef, @@ -177,584 +29,65 @@ export function useElementInteraction({ }: UseElementInteractionProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const sceneTracks = editor.scenes.getActiveScene().tracks; - const { - selectedElements, - isElementSelected, - selectElement, - handleElementClick: handleSelectionClick, - } = useElementSelection(); + const selection = useElementSelection(); - const [dragState, setDragState] = - useState(initialDragState); - const [dragDropTarget, setDragDropTarget] = useState(null); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef(null); - const moveGroupRef = useRef(null); - const newTrackIdsRef = useRef([]); - const groupMoveResultRef = useRef(null); - const lastMouseXRef = useRef(0); - const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); - - const startDrag = useCallback( - ({ - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - initialCurrentTime, - initialCurrentMouseY, - }: StartDragParams) => { - setDragState({ - isDragging: true, - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - currentTime: initialCurrentTime, - currentMouseY: initialCurrentMouseY, - }); + const deps: ElementInteractionDeps = { + viewport: { + getZoomLevel: () => zoomLevel, + getTracksScrollEl: () => tracksScrollRef.current, + getTracksContainerEl: () => tracksContainerRef.current, + getHeaderEl: () => headerRef?.current ?? null, }, - [], - ); - - const endDrag = useCallback(() => { - moveGroupRef.current = null; - newTrackIdsRef.current = []; - groupMoveResultRef.current = null; - setDragState(initialDragState); - setDragDropTarget(null); - }, []); - - const cancelCurrentDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownLocationRef.current = null; - setIsPendingDrag(false); - endDrag(); - onSnapPointChange?.(null); - }, [endDrag, onSnapPointChange]); - - const resolveGroupDragMove = useCallback( - ({ - group, - snappedTime, - dropTarget, - }: { - group: MoveGroup; - snappedTime: number; - dropTarget: DropTarget | null; - }): GroupMoveResult | null => { - if (!dropTarget) { - return null; - } - - if (dropTarget.isNewTrack) { - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); - } - - const orderedTracks = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ]; - const targetTrack = orderedTracks[dropTarget.trackIndex]; - if (!targetTrack) { - return null; - } - - const existingTrackResult = resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "existingTrack", - anchorTargetTrackId: targetTrack.id, - }, - }); - if (existingTrackResult) { - return existingTrackResult; - } - - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); + input: { + isShiftHeld: () => isShiftHeldRef.current, }, - [sceneTracks], - ); + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getActiveFps: () => editor.project.getActive()?.settings.fps ?? null, + }, + selection: { + getSelected: () => selection.selectedElements, + isSelected: selection.isElementSelected, + select: selection.selectElement, + handleClick: selection.handleElementClick, + clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(), + }, + playback: { + getCurrentTime: () => editor.playback.getCurrentTime(), + }, + timeline: { + moveElements: (args) => editor.timeline.moveElements(args), + }, + snap: { + isEnabled: () => snappingEnabled, + onChange: onSnapPointChange, + }, + }; + + const depsRef = useRef(deps); + depsRef.current = deps; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ElementInteractionController({ + depsRef: depsRef as ElementInteractionDepsRef, + }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - return registerCanceller({ fn: cancelCurrentDrag }); - }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); - - const getDragSnapResult = useCallback( - ({ - frameSnappedTime, - group, - }: { - frameSnappedTime: number; - group: MoveGroup | null; - }) => { - if (!group || !snappingEnabled || isShiftHeldRef.current) { - return { snappedTime: frameSnappedTime, snapPoint: null }; - } - - const groupSnap = snapGroupEdges({ - group, - anchorStartTime: frameSnappedTime, - tracks: sceneTracks, - playheadTime: editor.playback.getCurrentTime(), - zoomLevel, - }); - - return { - snappedTime: groupSnap.snappedAnchorStartTime, - snapPoint: groupSnap.snapPoint, - }; - }, - [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - let startedDragThisEvent = false; - const timeline = timelineRef.current; - const scrollContainer = tracksScrollRef.current; - if (!timeline || !scrollContainer) return; - lastMouseXRef.current = clientX; - - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - const activeProject = editor.project.getActive(); - if (!activeProject) return; - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = - roundToFrame({ - time: adjustedTime, - rate: activeProject.settings.fps, - }) ?? adjustedTime; - const moveGroup = buildMoveGroup({ - anchorRef: { - trackId: pendingDragRef.current.trackId, - elementId: pendingDragRef.current.elementId, - }, - selectedElements: pendingDragRef.current.selectedElements, - tracks: sceneTracks, - }); - if (!moveGroup) { - return; - } - - moveGroupRef.current = moveGroup; - newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record = {}; - for (const member of moveGroup.members) { - dragTimeOffsets[member.elementId] = member.timeOffset; - } - const { - snappedTime: initialSnappedTime, - snapPoint: initialSnapPoint, - } = getDragSnapResult({ - frameSnappedTime: snappedTime, - group: moveGroup, - }); - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: pendingDragRef.current.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: pendingDragRef.current.elementId, - trackId: pendingDragRef.current.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime: initialSnappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - anchorDropTarget != null - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime: initialSnappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - startDrag({ - ...pendingDragRef.current, - dragElementIds: moveGroup.members.map((member) => member.elementId), - dragTimeOffsets, - initialCurrentTime: initialSnappedTime, - initialCurrentMouseY: clientY, - }); - onSnapPointChange?.(initialSnapPoint); - startedDragThisEvent = true; - pendingDragRef.current = null; - setIsPendingDrag(false); - } else { - return; - } - } - - if (startedDragThisEvent) { - return; - } - - if (dragState.elementId && dragState.trackId) { - const alreadySelected = isElementSelected({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - if (!alreadySelected) { - selectElement({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - } - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const fps = activeProject.settings.fps; - const frameSnappedTime = - roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; - - const moveGroup = moveGroupRef.current; - const { snappedTime, snapPoint } = getDragSnapResult({ - frameSnappedTime, - group: moveGroup, - }); - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snappedTime, - currentMouseY: clientY, - })); - onSnapPointChange?.(snapPoint); - - if (dragState.elementId && dragState.trackId) { - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: dragState.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: dragState.elementId, - trackId: dragState.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - moveGroup && anchorDropTarget - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.startMouseY, - dragState.trackId, - zoomLevel, - isElementSelected, - selectElement, - editor.project, - timelineRef, - tracksScrollRef, - tracksContainerRef, - headerRef, - isPendingDrag, - startDrag, - getDragSnapResult, - resolveGroupDragMove, - sceneTracks, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); - if ( - deltaX <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - endDrag(); - onSnapPointChange?.(null); - return; - } - } - - const moveGroup = moveGroupRef.current; - if (!moveGroup) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const groupMoveResult = groupMoveResultRef.current; - if (!groupMoveResult) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const didMove = groupMoveResult.moves.some((move) => { - const currentMember = moveGroup.members.find( - (member) => member.elementId === move.elementId, - ); - const originalStartTime = - dragState.startElementTime + (currentMember?.timeOffset ?? 0); - return ( - currentMember?.trackId !== move.targetTrackId || - originalStartTime !== move.newStartTime - ); - }); - if (!didMove && groupMoveResult.createTracks.length === 0) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - editor.timeline.moveElements({ - moves: groupMoveResult.moves, - createTracks: groupMoveResult.createTracks, - }); - endDrag(); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.elementId, - dragState.startElementTime, - dragState.trackId, - endDrag, - onSnapPointChange, - editor.timeline, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag, onSnapPointChange]); - - const handleElementMouseDown = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: false, - }); - } - return; - } - - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - - if (isMultiSelect) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: true, - }); - } - - const clickOffsetTime = getClickOffsetTime({ - clientX: event.clientX, - elementRect: event.currentTarget.getBoundingClientRect(), - zoomLevel, - }); - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const pendingSelectedElements = isElementSelected(elementRef) - ? selectedElements - : [elementRef]; - pendingDragRef.current = { - elementId: element.id, - trackId: track.id, - selectedElements: pendingSelectedElements, - startMouseX: event.clientX, - startMouseY: event.clientY, - startElementTime: element.startTime, - clickOffsetTime, - }; - setIsPendingDrag(true); - }, - [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], - ); - - const handleElementClick = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - event.stopPropagation(); - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - return; - } - } - - // modifier keys already handled in mousedown - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected || selectedElements.length > 1) { - selectElement({ trackId: track.id, elementId: element.id }); - return; - } - - editor.selection.clearKeyframeSelection(); - }, - [editor.selection, isElementSelected, selectElement, selectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, + dragView: controller.view, + handleElementMouseDown: controller.onElementMouseDown, + handleElementClick: controller.onElementClick, }; } 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..5a1ff1f0 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,40 +1,15 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { getKeyframeById } from "@/animation"; 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 { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; -import { BatchCommand } from "@/commands"; -import type { SelectedKeyframeRef } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import type { Command } from "@/commands/base-command"; import { registerCanceller } from "@/editor/cancel-interaction"; -export interface KeyframeDragState { - isDragging: boolean; - draggingKeyframeIds: Set; - deltaTime: number; -} +import { + KeyframeDragController, + type KeyframeDragConfig, + type KeyframeDragState, +} from "@/timeline/controllers/keyframe-drag-controller"; +import type { TimelineElement } from "@/timeline"; -const initialDragState: KeyframeDragState = { - isDragging: false, - draggingKeyframeIds: new Set(), - deltaTime: 0, -}; - -interface PendingKeyframeDrag { - keyframeRefs: SelectedKeyframeRef[]; - startMouseX: number; -} +export type { KeyframeDragState }; export function useKeyframeDrag({ zoomLevel, @@ -54,275 +29,44 @@ export function useKeyframeDrag({ selectKeyframeRange, } = useKeyframeSelection(); - const [dragState, setDragState] = - useState(initialDragState); - const [isPendingDrag, setIsPendingDrag] = useState(false); - - const pendingDragRef = useRef(null); - const mouseDownXRef = useRef(null); - - const activeProject = editor.project.getActive(); - const fps = activeProject.settings.fps; - - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - - const endDrag = useCallback(() => { - setDragState(initialDragState); - }, []); - - const cancelDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownXRef.current = null; - setIsPendingDrag(false); - endDrag(); - }, [endDrag]); - - const commitDrag = useCallback( - ({ - keyframeRefs, - deltaTime, - }: { - keyframeRefs: SelectedKeyframeRef[]; - deltaTime: number; - }) => { - const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - if (!keyframe) return []; - const nextTime = Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTime), - ); - return [ - new RetimeKeyframeCommand({ - trackId: keyframeRef.trackId, - elementId: keyframeRef.elementId, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - nextTime, - }), - ]; - }); - - if (commands.length === 1) { - editor.command.execute({ command: commands[0] }); - } else if (commands.length > 1) { - editor.command.execute({ command: new BatchCommand(commands) }); - } - }, - [editor.command, element], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelDrag }); - }, [dragState.isDragging, isPendingDrag, cancelDrag]); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX }: MouseEvent) => { - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; - - const pending = pendingDragRef.current; - pendingDragRef.current = null; - setIsPendingDrag(false); - setDragState({ - isDragging: true, - draggingKeyframeIds: new Set( - pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), - ), - deltaTime: 0, - }); - return; - } - - 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; - - setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - const draggingRefs = selectedKeyframes.filter( - (keyframe) => - keyframe.elementId === element.id && - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - - if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { - commitDrag({ - keyframeRefs: draggingRefs, - deltaTime: dragState.deltaTime, - }); - } - - endDrag(); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.draggingKeyframeIds, - dragState.deltaTime, + const config: KeyframeDragConfig = { + zoomLevel, + element, + displayedStartTime, + getFps: () => editor.project.getActive()?.settings.fps ?? null, selectedKeyframes, - element.id, - commitDrag, - endDrag, - ]); + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + executeCommand: (command) => editor.command.execute({ command }), + seek: (args) => editor.playback.seek(args), + getTotalDuration: () => editor.timeline.getTotalDuration(), + }; + + const configRef = useRef(config); + configRef.current = config; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new KeyframeDragController({ configRef }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!isPendingDrag) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag]); - - const handleKeyframeMouseDown = useCallback( - ({ - event, - keyframes, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.preventDefault(); - event.stopPropagation(); - - mouseDownXRef.current = event.clientX; - - const anySelected = keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - - const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; - if (!anySelected && !isModifierKey) { - setKeyframeSelection({ keyframes }); - } - - const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; - - pendingDragRef.current = { - keyframeRefs: keyframeRefsToTrack, - startMouseX: event.clientX, - }; - setIsPendingDrag(true); - }, - [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], - ); - - const handleKeyframeClick = useCallback( - ({ - event, - keyframes, - orderedKeyframes, - indicatorTime, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => { - event.stopPropagation(); - - const wasDrag = - mouseDownXRef.current !== null && - Math.abs(event.clientX - mouseDownXRef.current) > - TIMELINE_DRAG_THRESHOLD_PX; - mouseDownXRef.current = null; - - if (wasDrag) return; - - const duration = editor.timeline.getTotalDuration(); - const seekTime = - snappedSeekTime({ - time: displayedStartTime + indicatorTime, - duration, - rate: fps, - }) ?? displayedStartTime + indicatorTime; - editor.playback.seek({ time: seekTime }); - - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } - - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }, - [ - toggleKeyframeSelection, - selectKeyframeRange, - editor, - displayedStartTime, - fps, - ], - ); - - const getVisualOffsetPx = useCallback( - ({ - indicatorTime, - indicatorOffsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }): number => { - if (!isBeingDragged) return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min(element.duration, indicatorTime + dragState.deltaTime), - ); - return ( - timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, - zoomLevel, - }) - elementLeft - ); - }, - [dragState.deltaTime, element.duration, zoomLevel], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - keyframeDragState: dragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, + keyframeDragState: controller.keyframeDragState, + handleKeyframeMouseDown: controller.onKeyframeMouseDown, + handleKeyframeClick: controller.onKeyframeClick, + getVisualOffsetPx: controller.getVisualOffsetPx, }; } 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..4ec2b80b 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,30 +1,9 @@ -import { useState, useCallback, type RefObject } from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; -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 { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { roundToFrame } from "opencut-wasm"; import { - buildTextElement, - buildGraphicElement, - buildStickerElement, - buildElementFromMedia, - buildEffectElement, -} from "@/timeline/element-utils"; -import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; -import { BatchCommand } from "@/commands"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getDragData, hasDragData } from "@/timeline/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/timeline"; -import type { - MediaDragData, - GraphicDragData, - StickerDragData, - EffectDragData, -} from "@/timeline/drag"; + DragDropController, + type DragDropConfig, +} from "@/timeline/controllers/drag-drop-controller"; interface UseTimelineDragDropProps { containerRef: RefObject; @@ -40,609 +19,47 @@ export function useTimelineDragDrop({ zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState(null); - const [dragElementType, setElementType] = useState(null); - const getSnappedTime = useCallback( - ({ time }: { time: number }) => { - const projectFps = editor.project.getActive().settings.fps; - return roundToFrame({ time, rate: projectFps }) ?? time; - }, - [editor], - ); + const config: DragDropConfig = { + zoomLevel, + getContainerEl: () => containerRef.current, + getHeaderEl: () => headerRef?.current ?? null, + getTracksScrollEl: () => tracksScrollRef?.current ?? null, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + getActiveProjectId: () => + editor.project.getActiveOrNull()?.metadata.id ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + dragSource: editor.timeline.dragSource, + addMediaAsset: (args) => editor.media.addMediaAsset(args), + executeCommand: (command) => editor.command.execute({ command }), + insertElement: (args) => editor.timeline.insertElement(args), + addClipEffect: (args) => editor.timeline.addClipEffect(args), + }; - const getElementType = useCallback( - ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { - const dragData = getDragData({ dataTransfer }); - if (!dragData) return null; + const configRef = useRef(config); + configRef.current = config; - if (dragData.type === "text") return "text"; - if (dragData.type === "graphic") return "graphic"; - if (dragData.type === "sticker") return "sticker"; - if (dragData.type === "effect") return "effect"; - if (dragData.type === "media") { - return dragData.mediaType; - } - return null; - }, - [], - ); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new DragDropController({ configRef }); + } + const controller = controllerRef.current; - const getElementDuration = useCallback( - ({ - elementType, - mediaId, - }: { - elementType: ElementType; - mediaId?: string; - }): number => { - if ( - elementType === "text" || - elementType === "graphic" || - elementType === "sticker" || - elementType === "effect" - ) { - return DEFAULT_NEW_ELEMENT_DURATION; - } - if (mediaId) { - const mediaAssets = editor.media.getAssets(); - const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration != null - ? Math.round(media.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - } - return DEFAULT_NEW_ELEMENT_DURATION; - }, - [editor], - ); - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.types.includes("Files"); - if (!hasAsset && !hasFiles) return; - setIsDragOver(true); - }, []); - - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const hasFiles = e.dataTransfer.types.includes("Files"); - const isExternal = - hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); - - const elementType = getElementType({ dataTransfer: e.dataTransfer }); - - if (!elementType && hasFiles && isExternal) { - setDropTarget(null); - setElementType(null); - return; - } - - if (!elementType) return; - - setElementType(elementType); - - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - const duration = getElementDuration({ - elementType, - mediaId: dragData?.type === "media" ? dragData.id : undefined, - }); - - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; - - const targetElementTypes = - dragData?.type === "effect" - ? (dragData as EffectDragData).targetElementTypes - : dragData?.type === "media" - ? (dragData as MediaDragData).targetElementTypes - : undefined; - - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const target = computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: isExternal, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - targetElementTypes, - }); - - target.xPosition = getSnappedTime({ time: target.xPosition }); - - setDropTarget(target); - e.dataTransfer.dropEffect = "copy"; - }, - [ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, - getElementType, - getElementDuration, - getSnappedTime, - editor, - ], - ); - - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - const { clientX, clientY } = e; - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - } - } - }, - [containerRef], - ); - - const executeTextDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: { name?: string; content?: string }; - }) => { - const element = buildTextElement({ - raw: { - name: dragData.name ?? "", - content: dragData.content ?? "", - }, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("text", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeStickerDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: StickerDragData; - }) => { - const element = buildStickerElement({ - stickerId: dragData.stickerId, - name: dragData.name, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeGraphicDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: GraphicDragData; - }) => { - const element = buildGraphicElement({ - definitionId: dragData.definitionId, - name: dragData.name, - startTime: target.xPosition, - params: dragData.params, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeMediaDrop = useCallback( - ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { - if (target.targetElement) { - toast.info("Replace media source is coming soon!"); - return; - } - - const mediaAssets = editor.media.getAssets(); - const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); - if (!mediaAsset) return; - - const trackType: TrackType = - dragData.mediaType === "audio" ? "audio" : "video"; - - const duration = - mediaAsset.duration != null - ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: mediaAsset.id, - mediaType: mediaAsset.type, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeEffectDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: EffectDragData; - }) => { - if (target.targetElement) { - editor.timeline.addClipEffect({ - trackId: target.targetElement.trackId, - elementId: target.targetElement.elementId, - effectType: dragData.effectType, - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const effectTrack = tracks.find((t) => t.type === "effect"); - let trackId: string; - - if (effectTrack) { - trackId = effectTrack.id; - } else if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element: buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }), - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } else { - const track = tracks[target.trackIndex]; - if (!track || track.type !== "effect") return; - trackId = track.id; - } - - const element = buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }); - - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - }, - [editor], - ); - - const executeFileDrop = useCallback( - async ({ - files, - mouseX, - mouseY, - }: { - files: File[]; - mouseX: number; - mouseY: number; - }) => { - const activeProject = editor.project.getActiveOrNull(); - if (!activeProject) return; - - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; - - for (const asset of processedAssets) { - const createdAsset = await editor.media.addMediaAsset({ - projectId, - asset, - }); - if (!createdAsset) continue; - - const duration = - createdAsset.duration != null - ? Math.round(createdAsset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const reuseMainTrackId = - createdAsset.type !== "audio" && - sceneTracks.overlay.length === 0 && - sceneTracks.audio.length === 0 && - sceneTracks.main.elements.length === 0 - ? sceneTracks.main.id - : null; - const dropTarget = reuseMainTrackId - ? null - : computeDropTarget({ - elementType: createdAsset.type, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = - createdAsset.type === "audio" ? "audio" : "video"; - - const startTime = dropTarget?.xPosition ?? currentTime; - const element = buildElementFromMedia({ - mediaId: createdAsset.id, - mediaType: createdAsset.type, - name: createdAsset.name, - duration, - startTime, - }); - - if (reuseMainTrackId) { - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: reuseMainTrackId }, - }), - }); - } else { - if (!dropTarget) continue; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - editor.command.execute({ - command: new BatchCommand([ - addTrackCmd, - new InsertElementCommand({ - element, - placement: { - mode: "explicit", - trackId: addTrackCmd.getTrackId(), - }, - }), - ]), - }); - } else { - const trackId = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ][dropTarget.trackIndex]?.id; - if (!trackId) continue; - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }), - }); - } - } - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - }, - [editor, zoomLevel], - ); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.files?.length > 0; - - if (!hasAsset && !hasFiles) return; - - const currentTarget = dropTarget; - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - - try { - if (hasAsset) { - if (!currentTarget) return; - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - if (!dragData) return; - - if (dragData.type === "text") { - executeTextDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "graphic") { - executeGraphicDrop({ - target: currentTarget, - dragData: dragData as GraphicDragData, - }); - } else if (dragData.type === "sticker") { - executeStickerDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "effect") { - executeEffectDrop({ - target: currentTarget, - dragData: dragData as EffectDragData, - }); - } else { - executeMediaDrop({ target: currentTarget, dragData }); - } - } else if (hasFiles) { - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = - e.clientY - referenceRect.top + scrollTop - headerHeight; - await executeFileDrop({ - files: Array.from(e.dataTransfer.files), - mouseX, - mouseY, - }); - } - } catch (err) { - console.error("Failed to process drop:", err); - } - }, - [ - dropTarget, - executeTextDrop, - executeGraphicDrop, - executeStickerDrop, - executeMediaDrop, - executeEffectDrop, - executeFileDrop, - containerRef, - headerRef, - tracksScrollRef, - ], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + useEffect(() => () => controller.destroy(), [controller]); return { - isDragOver, - dropTarget, - dragElementType, + isDragOver: controller.isDragOver, + dropTarget: controller.dropTarget, + dragElementType: controller.dragElementType, dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, + onDragEnter: controller.onDragEnter, + onDragOver: controller.onDragOver, + onDragLeave: controller.onDragLeave, + onDrop: controller.onDrop, }, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 7d9cf447..12bfe76a 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,23 +1,12 @@ -import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { useEffect, useCallback, useRef } from "react"; -import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useEffect, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { timelineTimeToPixels } from "@/timeline"; import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, -} from "@/timeline/snapping"; -import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; -import { - getCenteredLineLeft, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; + PlayheadController, + type PlayheadConfig, +} from "@/timeline/controllers/playhead-controller"; interface UseTimelinePlayheadProps { zoomLevel: number; @@ -35,267 +24,75 @@ export function useTimelinePlayhead({ playheadRef, }: UseTimelinePlayheadProps) { const editor = useEditor(); - const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const activeProject = editor.project.getActive(); - const duration = editor.timeline.getTotalDuration(); const isShiftHeldRef = useShiftKey(); + // isScrubbing drives useEdgeAutoScroll — the controller sets it on the editor, + // so this reactive read naturally reflects whether scrubbing is active. + const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const zoomLevelRef = useRef(zoomLevel); - const durationRef = useRef(duration); - const isScrubbingRef = useRef(isScrubbing); - const isPlayingRef = useRef(false); + const config: PlayheadConfig = { + zoomLevel, + duration: editor.timeline.getTotalDuration(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + isShiftHeld: () => isShiftHeldRef.current, + getIsPlaying: () => editor.playback.getIsPlaying(), + getRulerEl: () => rulerRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getPlayheadEl: () => playheadRef?.current ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getSceneBookmarks: () => editor.scenes.getActiveScene()?.bookmarks ?? [], + seek: (time) => editor.playback.seek({ time }), + setScrubbing: (scrubbing) => + editor.playback.setScrubbing({ isScrubbing: scrubbing }), + setTimelineViewState: (viewState) => + editor.project.setTimelineViewState({ viewState }), + }; + const configRef = useRef(config); + configRef.current = config; - useEffect(() => { - zoomLevelRef.current = zoomLevel; - durationRef.current = duration; - isScrubbingRef.current = isScrubbing; - isPlayingRef.current = editor.playback.getIsPlaying(); - }, [zoomLevel, duration, isScrubbing, editor.playback]); - - const seek = useCallback( - ({ time }: { time: number }) => editor.playback.seek({ time }), - [editor.playback], - ); - - const scrubTimeRef = useRef(null); - const isDraggingRulerRef = useRef(false); - const hasDraggedRulerRef = useRef(false); - const lastMouseXRef = useRef(0); - - const handleScrub = useCallback( - ({ - event, - snappingEnabled = true, - }: { - event: MouseEvent | React.MouseEvent; - snappingEnabled?: boolean; - }) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rulerRect = ruler.getBoundingClientRect(); - const relativeMouseX = event.clientX - rulerRect.left; - - const timelineContentWidth = timelineTimeToPixels({ - time: duration, - zoomLevel, - }); - - const clampedMouseX = Math.max( - 0, - Math.min(timelineContentWidth, relativeMouseX), - ); - - 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 rate = activeProject.settings.fps; - const frameTime = - snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; - - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - const time = (() => { - if (!shouldSnap) return frameTime; - const tracks = editor.scenes.getActiveScene().tracks; - const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks }), - () => getBookmarkSnapPoints({ bookmarks }), - () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), - ], - }); - const snapResult = resolveTimelineSnap({ - targetTime: frameTime, - snapPoints, - maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), - }); - return snapResult.snapPoint ? snapResult.snappedTime : frameTime; - })(); - - scrubTimeRef.current = time; - seek({ time }); - - lastMouseXRef.current = event.clientX; - }, - [ - duration, - zoomLevel, - seek, - rulerRef, - activeProject.settings.fps, - isShiftHeldRef, - editor.scenes, - ], - ); - - const handlePlayheadMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event }); - }, - [handleScrub, editor.playback], - ); - - const handleRulerMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - if (event.button !== 0) return; - if (playheadRef?.current?.contains(event.target as Node)) return; - - event.preventDefault(); - isDraggingRulerRef.current = true; - hasDraggedRulerRef.current = false; - - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], - ); - - const handlePlayheadMouseDownEvent = useCallback( - (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), - [handlePlayheadMouseDown], - ); - - const handleRulerMouseDownEvent = useCallback( - (event: React.MouseEvent) => handleRulerMouseDown({ event }), - [handleRulerMouseDown], - ); - - useEdgeAutoScroll({ - isActive: isScrubbing, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), - }); - - useEffect(() => { - if (!isScrubbing) return; - - const handleMouseMove = ({ event }: { event: MouseEvent }) => { - handleScrub({ event }); - if (isDraggingRulerRef.current) { - hasDraggedRulerRef.current = true; - } - }; - - const handleMouseUp = ({ event }: { event: MouseEvent }) => { - editor.playback.setScrubbing({ isScrubbing: false }); - const finalTime = scrubTimeRef.current; - if (finalTime !== null) { - seek({ time: finalTime }); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: finalTime, - }, - }); - } - scrubTimeRef.current = null; - - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - if (!hasDraggedRulerRef.current) { - handleScrub({ event, snappingEnabled: false }); - } - hasDraggedRulerRef.current = false; - } - }; - - const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); - const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); - - const updatePlayheadLeft = useCallback( - (time: number) => { - const playheadEl = playheadRef?.current; - if (!playheadEl) return; - const centerPosition = timelineTimeToSnappedPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); - const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; - playheadEl.style.left = `${leftPosition - scrollLeft}px`; - }, - [playheadRef, rulerScrollRef], - ); + const ctrlRef = useRef(null); + if (!ctrlRef.current) { + ctrlRef.current = new PlayheadController({ configRef }); + } + const ctrl = ctrlRef.current; + // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { const scrollEl = rulerScrollRef.current; if (!scrollEl) return; + const handler = () => + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + scrollEl.addEventListener("scroll", handler, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handler); + }, [ctrl, editor.playback, rulerScrollRef]); - const handleScroll = () => { - updatePlayheadLeft(editor.playback.getCurrentTime()); - }; - - scrollEl.addEventListener("scroll", handleScroll, { passive: true }); - return () => scrollEl.removeEventListener("scroll", handleScroll); - }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); - + // Playback events → update playhead position and auto-scroll during playback. useEffect(() => { - const handlePlaybackUpdate = (e: Event) => { - const time = (e as CustomEvent<{ time: number }>).detail.time; - updatePlayheadLeft(time); - - if (!isPlayingRef.current || isScrubbingRef.current) return; - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPixels = timelineTimeToPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; - - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }; - - const initialTime = editor.playback.getCurrentTime(); - handlePlaybackUpdate({ - detail: { time: initialTime }, - } as CustomEvent<{ time: number }>); - - window.addEventListener("playback-update", handlePlaybackUpdate); - window.addEventListener("playback-seek", handlePlaybackUpdate); + const handler = (time: number) => ctrl.handlePlaybackUpdate(time); + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + const unsubscribeUpdate = editor.playback.onUpdate(handler); + const unsubscribeSeek = editor.playback.onSeek(handler); return () => { - window.removeEventListener("playback-update", handlePlaybackUpdate); - window.removeEventListener("playback-seek", handlePlaybackUpdate); + unsubscribeUpdate(); + unsubscribeSeek(); }; - }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); + }, [ctrl, editor.playback]); + + useEdgeAutoScroll({ + isActive: isScrubbing, + getMouseClientX: () => ctrl.getLastMouseClientX(), + rulerScrollRef, + tracksScrollRef, + contentWidth: timelineTimeToPixels({ + time: editor.timeline.getTotalDuration(), + zoomLevel, + }), + }); + + useEffect(() => () => ctrl.destroy(), [ctrl]); return { - handlePlayheadMouseDown: handlePlayheadMouseDownEvent, - handleRulerMouseDown: handleRulerMouseDownEvent, + handlePlayheadMouseDown: ctrl.onPlayheadMouseDown, + handleRulerMouseDown: ctrl.onRulerMouseDown, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index fb8da291..46363fb6 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,332 +1,81 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { EditorCore } from "@/core"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; -import { - computeGroupResize, - type GroupResizeMember, - type GroupResizeResult, - type ResizeSide, -} from "@/timeline/group-resize"; -import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, - type SnapPoint, -} from "@/timeline/snapping"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; -import { isRetimableElement } from "@/timeline"; import { registerCanceller } from "@/editor/cancel-interaction"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + ResizeController, + type ResizeConfig, +} from "@/timeline/controllers/resize-controller"; +import type { ResizeSide } from "@/timeline/group-resize"; +import type { SnapPoint } from "@/timeline/snapping"; -interface ResizeInteractionState { - side: ResizeSide; - startX: number; - members: GroupResizeMember[]; +export type { ResizeSide }; + +interface UseTimelineResizeProps { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } export function useTimelineResize({ zoomLevel, onSnapPointChange, -}: { - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}) { +}: UseTimelineResizeProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const { selectedElements } = useElementSelection(); - const [resizeState, setResizeState] = useState( - null, - ); - const latestResultRef = useRef(null); - const cancelResize = useCallback(() => { - editor.timeline.discardPreview(); - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }, [editor.timeline, onSnapPointChange]); - - useEffect(() => { - if (!resizeState) { - return; - } - - return registerCanceller({ fn: cancelResize }); - }, [resizeState, cancelResize]); - - const handleResizeStart = useCallback( - ({ - event, - element, - track, - side, - }: { - event: React.MouseEvent; - element: TimelineElement; - track: TimelineTrack; - side: ResizeSide; - }) => { - event.stopPropagation(); - event.preventDefault(); - - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const activeSelection = selectedElements.some( - (selectedElement) => - selectedElement.trackId === track.id && - selectedElement.elementId === element.id, - ) - ? selectedElements - : [elementRef]; - const members = buildResizeMembers({ - tracks: editor.scenes.getActiveScene().tracks, - selectedElements: activeSelection, - }); - if (members.length === 0) { - return; - } - - editor.timeline.discardPreview(); - latestResultRef.current = null; - setResizeState({ - side, - startX: event.clientX, - members, - }); - }, - [selectedElements, editor], - ); - - useEffect(() => { - if (!resizeState) { - return; - } - - const handleMouseMove = ({ clientX }: MouseEvent) => { - const deltaX = clientX - resizeState.startX; - const rawDeltaTime = Math.round( - (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * - TICKS_PER_SECOND, - ); - const snappedDeltaTime = getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap: snappingEnabled && !isShiftHeldRef.current, - onSnapPointChange, - }); - const fps = editor.project.getActive().settings.fps; - const result = computeGroupResize({ - members: resizeState.members, - side: resizeState.side, - deltaTime: snappedDeltaTime.deltaTime, - fps, - }); - - latestResultRef.current = result; + const config: ResizeConfig = { + zoomLevel, + snappingEnabled, + isShiftHeld: () => isShiftHeldRef.current, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + selectedElements, + discardPreview: () => editor.timeline.discardPreview(), + previewElements: (updates) => editor.timeline.previewElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ + updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, updates: patch, })), - }); - }; - - const handleMouseUp = () => { - const result = latestResultRef.current; - editor.timeline.discardPreview(); - if ( - result && - hasResizeChanges({ members: resizeState.members, result }) - ) { - editor.timeline.updateElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ - trackId, - elementId, - patch, - })), - }); - } - - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - resizeState, - zoomLevel, - snappingEnabled, - isShiftHeldRef, - editor, + }), + commitElements: (updates) => + editor.timeline.updateElements({ + updates: updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + patch, + })), + }), onSnapPointChange, - ]); + }; + + const configRef = useRef(config); + configRef.current = config; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ResizeController({ configRef }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + + useEffect(() => { + if (!controller.isResizing) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isResizing, controller]); + + useEffect(() => () => controller.destroy(), [controller]); return { - isResizing: resizeState !== null, - handleResizeStart, + isResizing: controller.isResizing, + handleResizeStart: controller.onResizeStart, }; } - -function buildResizeMembers({ - tracks, - selectedElements, -}: { - tracks: SceneTracks; - selectedElements: Array<{ trackId: string; elementId: string }>; -}): GroupResizeMember[] { - const selectedElementIds = new Set( - selectedElements.map((selectedElement) => selectedElement.elementId), - ); - const trackMap = new Map( - [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ - track.id, - track, - ]), - ); - - return selectedElements.flatMap(({ trackId, elementId }) => { - const track = trackMap.get(trackId); - const element = track?.elements.find( - (trackElement) => trackElement.id === elementId, - ); - if (!track || !element) { - return []; - } - - const otherElements = track.elements.filter( - (trackElement) => !selectedElementIds.has(trackElement.id), - ); - const leftNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime + trackElement.duration <= element.startTime, - ) - .reduce((bound, trackElement) => { - return Math.max(bound, trackElement.startTime + trackElement.duration); - }, -Infinity); - const rightNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime >= element.startTime + element.duration, - ) - .reduce((bound, trackElement) => { - return Math.min(bound, trackElement.startTime); - }, Infinity); - - return [ - { - trackId, - elementId, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - sourceDuration: element.sourceDuration, - retime: isRetimableElement(element) ? element.retime : undefined, - leftNeighborBound, - rightNeighborBound, - }, - ]; - }); -} - -function getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap, - onSnapPointChange, -}: { - editor: EditorCore; - resizeState: ResizeInteractionState; - rawDeltaTime: number; - zoomLevel: number; - shouldSnap: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}): { deltaTime: number } { - if (!shouldSnap) { - onSnapPointChange?.(null); - return { deltaTime: rawDeltaTime }; - } - - const tracks = editor.scenes.getActiveScene().tracks; - const playheadTime = editor.playback.getCurrentTime(); - const excludeElementIds = new Set( - resizeState.members.map((member) => member.elementId), - ); - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), - () => getPlayheadSnapPoints({ playheadTime }), - () => - getAnimationKeyframeSnapPointsForTimeline({ - tracks, - excludeElementIds, - }), - ], - }); - const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); - let closestSnapPoint: SnapPoint | null = null; - let closestSnapDistance = Infinity; - let deltaTime = rawDeltaTime; - - for (const member of resizeState.members) { - const baseEdgeTime = - resizeState.side === "left" - ? member.startTime - : member.startTime + member.duration; - const snapResult = resolveTimelineSnap({ - targetTime: baseEdgeTime + rawDeltaTime, - snapPoints, - maxSnapDistance, - }); - if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { - closestSnapDistance = snapResult.snapDistance; - closestSnapPoint = snapResult.snapPoint; - deltaTime = snapResult.snappedTime - baseEdgeTime; - } - } - - onSnapPointChange?.(closestSnapPoint); - return { deltaTime }; -} - -function hasResizeChanges({ - members, - result, -}: { - members: GroupResizeMember[]; - result: GroupResizeResult; -}): boolean { - return result.updates.some((update) => { - const member = members.find( - (candidateMember) => candidateMember.elementId === update.elementId, - ); - return ( - member?.trimStart !== update.patch.trimStart || - member?.trimEnd !== update.patch.trimEnd || - member?.startTime !== update.patch.startTime || - member?.duration !== update.patch.duration - ); - }); -} diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 889177c9..56f68ed6 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,9 +1,9 @@ -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 { useEffect, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { + SeekController, + type SeekConfig, +} from "@/timeline/controllers/seek-controller"; interface UseTimelineSeekProps { playheadRef: RefObject; @@ -17,44 +17,6 @@ interface UseTimelineSeekProps { seek: (time: number) => void; } -function resetMouseTracking({ - mouseTrackingRef, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; -}) { - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; -} - -function setMouseTracking({ - mouseTrackingRef, - event, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; - event: React.MouseEvent; -}) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: event.clientX, - downY: event.clientY, - downTime: event.timeStamp, - }; -} - export function useTimelineSeek({ playheadRef, trackLabelsRef, @@ -67,131 +29,36 @@ export function useTimelineSeek({ seek, }: UseTimelineSeekProps) { const editor = useEditor(); - const activeProject = editor.project.getActive(); + const config: SeekConfig = { + zoomLevel, + duration, + isSelecting, + getPlayheadEl: () => playheadRef.current, + getTrackLabelsEl: () => trackLabelsRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + clearSelectedElements, + seek, + setTimelineViewState: (viewState) => + editor.project.setTimelineViewState({ viewState }), + }; - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); + const configRef = useRef(config); + configRef.current = config; - const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new SeekController({ configRef }); + } + const controller = controllerRef.current; - const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const shouldProcessTimelineClick = useCallback( - ({ event }: { event: React.MouseEvent }) => { - const target = event.target as HTMLElement; - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - const deltaX = Math.abs(event.clientX - downX); - const deltaY = Math.abs(event.clientY - downY); - const deltaTime = event.timeStamp - downTime; - const isPlayhead = !!playheadRef.current?.contains(target); - const isTrackLabels = !!trackLabelsRef.current?.contains(target); - const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; - - if (!isMouseDown) return false; - if (shouldBlockForDrag) return false; - if (isSelecting) return false; - if (isPlayhead) return false; - if (isTrackLabels) { - clearSelectedElements(); - return false; - } - - return true; - }, - [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], - ); - - const handleTimelineSeek = useCallback( - ({ - event, - source, - }: { - event: React.MouseEvent; - source: "ruler" | "tracks"; - }) => { - const scrollContainer = - source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; - - if (!scrollContainer) return; - - const rect = scrollContainer.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const scrollLeft = scrollContainer.scrollLeft; - - 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 rate = activeProject?.settings.fps; - const time = rate - ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) - : rawTime; - seek(time); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollContainer.scrollLeft, - playheadTime: time, - }, - }); - }, - [ - duration, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - seek, - editor, - activeProject?.settings.fps.numerator, - activeProject?.settings.fps.denominator, - ], - ); - - const handleTracksClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); - - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "tracks" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); - - const handleRulerClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); - - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "ruler" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - handleTracksMouseDown, - handleTracksClick, - handleRulerMouseDown, - handleRulerClick, + handleTracksMouseDown: controller.onTracksMouseDown, + handleTracksClick: controller.onTracksClick, + handleRulerMouseDown: controller.onRulerMouseDown, + handleRulerClick: controller.onRulerClick, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 7929d495..a8825a37 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -1,17 +1,17 @@ import { type WheelEvent as ReactWheelEvent, type RefObject, - useCallback, useEffect, useLayoutEffect, + useReducer, useRef, - useState, } from "react"; -import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; -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 { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; +import { + ZoomController, + type ZoomConfig, +} from "@/timeline/controllers/zoom-controller"; interface UseTimelineZoomProps { containerRef: RefObject; @@ -40,252 +40,53 @@ export function useTimelineZoom({ rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn { const editor = useEditor(); - const hasInitializedRef = useRef(false); - const hasRestoredPlayheadRef = useRef(false); - const scrollSaveTimeoutRef = useRef | null>( - null, - ); + const config: ZoomConfig = { + minZoom, + getContainerEl: () => containerRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + seek: (time) => editor.playback.seek({ time }), + setTimelineViewState: (viewState) => + editor.project.setTimelineViewState({ viewState }), + }; + const configRef = useRef(config); + configRef.current = config; - const [zoomLevel, setZoomLevelRaw] = useState(() => { - if (initialZoom !== undefined) { - hasInitializedRef.current = true; - return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)); - } - return minZoom; - }); - const previousZoomRef = useRef(zoomLevel); - const hasRestoredScrollRef = useRef(false); - const preZoomScrollLeftRef = useRef(0); - const prePlayheadAnchorScrollLeftRef = useRef(0); - const isInPlayheadAnchorModeRef = useRef(false); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ZoomController({ configRef, initialZoom }); + } + const controller = controllerRef.current; + const zoomLevel = controller.zoomLevel; - const setZoomLevel = useCallback( - (updater: number | ((prev: number) => number)) => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - preZoomScrollLeftRef.current = scrollElement.scrollLeft; - } - setZoomLevelRaw(updater); - }, - [tracksScrollRef], - ); - - const handleWheel = useCallback( - (event: ReactWheelEvent) => { - const isZoomGesture = event.ctrlKey || event.metaKey; - const isHorizontalScrollGesture = - event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); - - if (isHorizontalScrollGesture) { - return; - } - - // pinch-zoom (ctrl/meta + wheel) - if (isZoomGesture) { - const normalizedDelta = - event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; - const cappedDelta = - Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - setZoomLevel((prev) => { - const nextZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor), - ); - return nextZoom; - }); - return; - } - }, - [minZoom, setZoomLevel], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (initialZoom !== undefined && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); - return; - } - setZoomLevel((prev) => { - if (prev < minZoom) { - return minZoom; - } - return prev; - }); - }, [minZoom, initialZoom, setZoomLevel]); - - const wrappedSetZoomLevel = useCallback( - (zoomLevelOrUpdater: number | ((prev: number) => number)) => { - setZoomLevel((prev) => { - const nextZoom = - typeof zoomLevelOrUpdater === "function" - ? zoomLevelOrUpdater(prev) - : zoomLevelOrUpdater; - const clampedZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, nextZoom), - ); - return clampedZoom; - }); - }, - [minZoom, setZoomLevel], - ); + controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { - const previousZoom = previousZoomRef.current; - if (previousZoom === zoomLevel) return; - - const scrollElement = tracksScrollRef.current; - if (!scrollElement) { - previousZoomRef.current = zoomLevel; - return; - } - - const currentScrollLeft = preZoomScrollLeftRef.current; - const playheadTime = editor.playback.getCurrentTime(); - const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); - const previousSliderPercent = zoomToSlider({ - zoomLevel: previousZoom, - minZoom, - }); - const isCrossingThresholdUp = - previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - const isCrossingThresholdDown = - previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - - const syncScroll = (scrollLeft: number) => { - scrollElement.scrollLeft = scrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = scrollLeft; - } - }; - - const clampScrollLeft = (scrollLeft: number) => { - const maxScrollLeft = - scrollElement.scrollWidth - scrollElement.clientWidth; - return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); - }; - - if (isCrossingThresholdUp) { - prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; - isInPlayheadAnchorModeRef.current = true; - } - - if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { - const playheadPixelsBefore = timelineTimeToPixels({ - time: playheadTime, - zoomLevel: previousZoom, - }); - const playheadPixelsAfter = timelineTimeToPixels({ - time: playheadTime, - zoomLevel, - }); - - const viewportOffset = playheadPixelsBefore - currentScrollLeft; - const newScrollLeft = playheadPixelsAfter - viewportOffset; - - syncScroll(clampScrollLeft(newScrollLeft)); - } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { - syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); - isInPlayheadAnchorModeRef.current = false; - } - - previousZoomRef.current = zoomLevel; - - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime, - }, - }); - }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref - const saveScrollPosition = useCallback(() => { - if (scrollSaveTimeoutRef.current) { - clearTimeout(scrollSaveTimeoutRef.current); - } - scrollSaveTimeoutRef.current = setTimeout(() => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime: editor.playback.getCurrentTime(), - }, - }); - } - }, 300); - }, [zoomLevel, editor]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable - useEffect(() => { - if (initialScrollLeft === undefined) return; - if (hasRestoredScrollRef.current) return; - const scrollElement = tracksScrollRef.current; - if (!scrollElement) return; - - const restoreScroll = () => { - scrollElement.scrollLeft = initialScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = initialScrollLeft; - } - hasRestoredScrollRef.current = true; - }; - - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - } else { - const observer = new ResizeObserver(() => { - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - observer.disconnect(); - } - }); - observer.observe(scrollElement); - return () => observer.disconnect(); - } - }, [initialScrollLeft]); + controller.applyZoomLayout(zoomLevel); + }, [controller, zoomLevel]); useEffect(() => { - if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { - hasRestoredPlayheadRef.current = true; - editor.playback.seek({ time: initialPlayheadTime }); - } - }, [initialPlayheadTime, editor]); + return controller.restoreInitialScrollIfNeeded(initialScrollLeft); + }, [controller, initialScrollLeft]); - // prevent browser zoom in the timeline useEffect(() => { - const preventZoom = (event: WheelEvent) => { - const isZoomKeyPressed = event.ctrlKey || event.metaKey; - const isInContainer = containerRef.current?.contains( - event.target as Node, - ); - // only check isInContainer, not isInTimeline state - the state check - // causes race conditions where the closure captures stale state - if (isZoomKeyPressed && isInContainer) { - event.preventDefault(); - } - }; + controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime); + }, [controller, initialPlayheadTime]); - document.addEventListener("wheel", preventZoom, { - passive: false, - capture: true, - }); + useEffect(() => controller.bindPreventBrowserZoom(), [controller]); - return () => { - document.removeEventListener("wheel", preventZoom, { capture: true }); - }; - }, [containerRef]); + useEffect(() => () => controller.destroy(), [controller]); return { zoomLevel, - setZoomLevel: wrappedSetZoomLevel, - handleWheel, - saveScrollPosition, + setZoomLevel: controller.setZoomLevel, + handleWheel: controller.handleWheel, + saveScrollPosition: controller.saveScrollPosition, }; } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index cd543cdf..338da9d9 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -1,314 +1,317 @@ -import type { ElementAnimations } from "@/animation/types"; -import type { Effect } from "@/effects/types"; -import type { Mask } from "@/masks/types"; -import type { ParamValues } from "@/params"; -import type { BlendMode, Transform } from "@/rendering"; - -export type ElementRef = { - trackId: string; - elementId: string; -}; - -export interface Bookmark { - time: number; - note?: string; - color?: string; - duration?: number; -} - -export interface TScene { - id: string; - name: string; - isMain: boolean; - tracks: SceneTracks; - bookmarks: Bookmark[]; - createdAt: Date; - updatedAt: Date; -} - -export type TrackType = "video" | "text" | "audio" | "graphic" | "effect"; - -interface BaseTrack { - id: string; - name: string; -} - -export interface VideoTrack extends BaseTrack { - type: "video"; - elements: (VideoElement | ImageElement)[]; - muted: boolean; - hidden: boolean; -} - -export interface TextTrack extends BaseTrack { - type: "text"; - elements: TextElement[]; - hidden: boolean; -} - -export interface AudioTrack extends BaseTrack { - type: "audio"; - elements: AudioElement[]; - muted: boolean; -} - -export interface GraphicTrack extends BaseTrack { - type: "graphic"; - elements: (StickerElement | GraphicElement)[]; - hidden: boolean; -} - -export interface EffectTrack extends BaseTrack { - type: "effect"; - elements: EffectElement[]; - hidden: boolean; -} - -export type TimelineTrack = - | VideoTrack - | TextTrack - | AudioTrack - | GraphicTrack - | EffectTrack; - -export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack; - -export interface SceneTracks { - overlay: OverlayTrack[]; - main: VideoTrack; - audio: AudioTrack[]; -} - -export interface RetimeConfig { - rate: number; - maintainPitch?: boolean; -} - -interface BaseAudioElement extends BaseTimelineElement { - type: "audio"; - volume: number; - muted?: boolean; - buffer?: AudioBuffer; - retime?: RetimeConfig; -} - -export interface UploadAudioElement extends BaseAudioElement { - sourceType: "upload"; - mediaId: string; -} - -export interface LibraryAudioElement extends BaseAudioElement { - sourceType: "library"; - sourceUrl: string; -} - -export type AudioElement = UploadAudioElement | LibraryAudioElement; - -interface BaseTimelineElement { - id: string; - name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; - animations?: ElementAnimations; -} - -export interface VideoElement extends BaseTimelineElement { - type: "video"; - mediaId: string; - volume?: number; - muted?: boolean; - isSourceAudioEnabled?: boolean; - hidden?: boolean; - retime?: RetimeConfig; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface ImageElement extends BaseTimelineElement { - type: "image"; - mediaId: string; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface TextBackground { - enabled: boolean; - color: string; - cornerRadius?: number; - paddingX?: number; - paddingY?: number; - offsetX?: number; - offsetY?: number; -} - -export interface TextElement extends BaseTimelineElement { - type: "text"; - content: string; - fontSize: number; - fontFamily: string; - color: string; - background: TextBackground; - textAlign: "left" | "center" | "right"; - fontWeight: "normal" | "bold"; - fontStyle: "normal" | "italic"; - textDecoration: "none" | "underline" | "line-through"; - letterSpacing?: number; - lineHeight?: number; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; -} - -export interface StickerElement extends BaseTimelineElement { - type: "sticker"; - stickerId: string; - /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */ - intrinsicWidth?: number; - intrinsicHeight?: number; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; -} - -export interface GraphicElement extends BaseTimelineElement { - type: "graphic"; - definitionId: string; - params: ParamValues; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface EffectElement extends BaseTimelineElement { - type: "effect"; - effectType: string; - params: ParamValues; -} - -export type ElementUpdatePatch = - | { transform: Transform } - | { opacity: number } - | { volume: number }; - -export type TimelineElement = - | AudioElement - | VideoElement - | ImageElement - | TextElement - | StickerElement - | GraphicElement - | EffectElement; - -export type ElementType = TimelineElement["type"]; - -function elementTypes(...types: T): T { - return types; -} - -export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic"); - -export type MaskableElement = Extract< - TimelineElement, - { type: (typeof MASKABLE_ELEMENT_TYPES)[number] } ->; - -export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio"); - -export type RetimableElement = Extract< - TimelineElement, - { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] } ->; - -export const VISUAL_ELEMENT_TYPES = elementTypes( - "video", - "image", - "text", - "sticker", - "graphic", -); - -export type VisualElement = Extract< - TimelineElement, - { type: (typeof VISUAL_ELEMENT_TYPES)[number] } ->; - -export type CreateUploadAudioElement = Omit; -export type CreateLibraryAudioElement = Omit; -export type CreateAudioElement = - | CreateUploadAudioElement - | CreateLibraryAudioElement; -export type CreateVideoElement = Omit; -export type CreateImageElement = Omit; -export type CreateTextElement = Omit; -export type CreateStickerElement = Omit; -export type CreateGraphicElement = Omit; -export type CreateEffectElement = Omit; -export type CreateTimelineElement = - | CreateAudioElement - | CreateVideoElement - | CreateImageElement - | CreateTextElement - | CreateStickerElement - | CreateGraphicElement - | CreateEffectElement; - -export interface ElementDragState { - isDragging: boolean; - elementId: string | null; - dragElementIds: string[]; - dragTimeOffsets: Record; - trackId: string | null; - startMouseX: number; - startMouseY: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; - currentMouseY: number; -} - -export interface DropTarget { - trackIndex: number; - isNewTrack: boolean; - insertPosition: "above" | "below" | null; - xPosition: number; - targetElement: { elementId: string; trackId: string } | null; -} - -export interface ComputeDropTargetParams { - elementType: ElementType; - mouseX: number; - mouseY: number; - tracks: SceneTracks; - playheadTime: number; - isExternalDrop: boolean; - elementDuration: number; - pixelsPerSecond: number; - zoomLevel: number; - verticalDragDirection?: "up" | "down" | null; - startTimeOverride?: number; - excludeElementId?: string; - targetElementTypes?: string[]; -} - -export interface ClipboardItem { - trackId: string; - trackType: TrackType; - element: CreateTimelineElement; -} +import type { ElementAnimations } from "@/animation/types"; +import type { Effect } from "@/effects/types"; +import type { Mask } from "@/masks/types"; +import type { ParamValues } from "@/params"; +import type { BlendMode, Transform } from "@/rendering"; + +export type ElementRef = { + trackId: string; + elementId: string; +}; + +export interface Bookmark { + time: number; + note?: string; + color?: string; + duration?: number; +} + +export interface TScene { + id: string; + name: string; + isMain: boolean; + tracks: SceneTracks; + bookmarks: Bookmark[]; + createdAt: Date; + updatedAt: Date; +} + +export type TrackType = "video" | "text" | "audio" | "graphic" | "effect"; + +interface BaseTrack { + id: string; + name: string; +} + +export interface VideoTrack extends BaseTrack { + type: "video"; + elements: (VideoElement | ImageElement)[]; + muted: boolean; + hidden: boolean; +} + +export interface TextTrack extends BaseTrack { + type: "text"; + elements: TextElement[]; + hidden: boolean; +} + +export interface AudioTrack extends BaseTrack { + type: "audio"; + elements: AudioElement[]; + muted: boolean; +} + +export interface GraphicTrack extends BaseTrack { + type: "graphic"; + elements: (StickerElement | GraphicElement)[]; + hidden: boolean; +} + +export interface EffectTrack extends BaseTrack { + type: "effect"; + elements: EffectElement[]; + hidden: boolean; +} + +export type TimelineTrack = + | VideoTrack + | TextTrack + | AudioTrack + | GraphicTrack + | EffectTrack; + +export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack; + +export interface SceneTracks { + overlay: OverlayTrack[]; + main: VideoTrack; + audio: AudioTrack[]; +} + +export interface RetimeConfig { + rate: number; + maintainPitch?: boolean; +} + +interface BaseAudioElement extends BaseTimelineElement { + type: "audio"; + volume: number; + muted?: boolean; + buffer?: AudioBuffer; + retime?: RetimeConfig; +} + +export interface UploadAudioElement extends BaseAudioElement { + sourceType: "upload"; + mediaId: string; +} + +export interface LibraryAudioElement extends BaseAudioElement { + sourceType: "library"; + sourceUrl: string; +} + +export type AudioElement = UploadAudioElement | LibraryAudioElement; + +interface BaseTimelineElement { + id: string; + name: string; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; + sourceDuration?: number; + animations?: ElementAnimations; +} + +export interface VideoElement extends BaseTimelineElement { + type: "video"; + mediaId: string; + volume?: number; + muted?: boolean; + isSourceAudioEnabled?: boolean; + hidden?: boolean; + retime?: RetimeConfig; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface ImageElement extends BaseTimelineElement { + type: "image"; + mediaId: string; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface TextBackground { + enabled: boolean; + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; +} + +export interface TextElement extends BaseTimelineElement { + type: "text"; + content: string; + fontSize: number; + fontFamily: string; + color: string; + background: TextBackground; + textAlign: "left" | "center" | "right"; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + textDecoration: "none" | "underline" | "line-through"; + letterSpacing?: number; + lineHeight?: number; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface StickerElement extends BaseTimelineElement { + type: "sticker"; + stickerId: string; + /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */ + intrinsicWidth?: number; + intrinsicHeight?: number; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface GraphicElement extends BaseTimelineElement { + type: "graphic"; + definitionId: string; + params: ParamValues; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface EffectElement extends BaseTimelineElement { + type: "effect"; + effectType: string; + params: ParamValues; +} + +export type ElementUpdatePatch = + | { transform: Transform } + | { opacity: number } + | { volume: number }; + +export type TimelineElement = + | AudioElement + | VideoElement + | ImageElement + | TextElement + | StickerElement + | GraphicElement + | EffectElement; + +export type ElementType = TimelineElement["type"]; + +function elementTypes(...types: T): T { + return types; +} + +export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic"); + +export type MaskableElement = Extract< + TimelineElement, + { type: (typeof MASKABLE_ELEMENT_TYPES)[number] } +>; + +export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio"); + +export type RetimableElement = Extract< + TimelineElement, + { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] } +>; + +export const VISUAL_ELEMENT_TYPES = elementTypes( + "video", + "image", + "text", + "sticker", + "graphic", +); + +export type VisualElement = Extract< + TimelineElement, + { type: (typeof VISUAL_ELEMENT_TYPES)[number] } +>; + +export type CreateUploadAudioElement = Omit; +export type CreateLibraryAudioElement = Omit; +export type CreateAudioElement = + | CreateUploadAudioElement + | CreateLibraryAudioElement; +export type CreateVideoElement = Omit; +export type CreateImageElement = Omit; +export type CreateTextElement = Omit; +export type CreateStickerElement = Omit; +export type CreateGraphicElement = Omit; +export type CreateEffectElement = Omit; +export type CreateTimelineElement = + | CreateAudioElement + | CreateVideoElement + | CreateImageElement + | CreateTextElement + | CreateStickerElement + | CreateGraphicElement + | CreateEffectElement; + +export type ElementDragView = + | { readonly kind: "idle" } + | { + readonly kind: "dragging"; + readonly anchorElementId: string; + readonly trackId: string; + readonly memberTimeOffsets: ReadonlyMap; + readonly startMouseX: number; + readonly startMouseY: number; + readonly startElementTime: number; + readonly clickOffsetTime: number; + readonly currentTime: number; + readonly currentMouseX: number; + readonly currentMouseY: number; + readonly dropTarget: DropTarget | null; + }; + +export interface DropTarget { + trackIndex: number; + isNewTrack: boolean; + insertPosition: "above" | "below" | null; + xPosition: number; + targetElement: { elementId: string; trackId: string } | null; +} + +export interface ComputeDropTargetParams { + elementType: ElementType; + mouseX: number; + mouseY: number; + tracks: SceneTracks; + playheadTime: number; + isExternalDrop: boolean; + elementDuration: number; + pixelsPerSecond: number; + zoomLevel: number; + verticalDragDirection?: "up" | "down" | null; + startTimeOverride?: number; + excludeElementId?: string; + targetElementTypes?: string[]; +} + +export interface ClipboardItem { + trackId: string; + trackType: TrackType; + element: CreateTimelineElement; +} diff --git a/rust/crates/compositor/src/compositor.rs b/rust/crates/compositor/src/compositor.rs index 84bcceae..99f0e7e9 100644 --- a/rust/crates/compositor/src/compositor.rs +++ b/rust/crates/compositor/src/compositor.rs @@ -348,7 +348,6 @@ impl Compositor { ) -> Result<(), CompositorError> { let frame = options.frame; self.texture_pool.recycle_frame(); - context.configure_surface(options.surface, frame.width, frame.height)?; let surface_texture = context.acquire_surface_texture(options.surface)?; let surface_view = surface_texture .texture diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs index 0fcb2e8c..da835280 100644 --- a/rust/crates/gpu/src/context.rs +++ b/rust/crates/gpu/src/context.rs @@ -1,5 +1,8 @@ use wgpu::util::DeviceExt; +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +use std::cell::RefCell; + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] use wasm_bindgen::JsCast; @@ -17,6 +20,12 @@ impl wgpu::rwh::HasDisplayHandle for WebDisplay { } } +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +struct CachedCanvasSurface { + surface: wgpu::Surface<'static>, + size: (u32, u32), +} + const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl"); const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [ @@ -44,6 +53,8 @@ pub struct GpuContext { /// fallback path. Used by render_texture_via_gl_canvas to output frames on WebGL. #[cfg(all(feature = "wasm", target_arch = "wasm32"))] gl_canvas: Option, + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + gl_surface: RefCell>, } impl GpuContext { @@ -170,6 +181,8 @@ impl GpuContext { supports_external_texture_copies, #[cfg(all(feature = "wasm", target_arch = "wasm32"))] gl_canvas, + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + gl_surface: RefCell::new(None), }) } @@ -184,17 +197,15 @@ impl GpuContext { ), GpuError, > { - // Temporary fix: force the wasm renderer onto the WebGL backend even when - // WebGPU is available while a WebGPU bug is being investigated. - // let instance = wgpu::util::new_instance_with_webgpu_detection( - // wgpu::InstanceDescriptor::new_without_display_handle(), - // ) - // .await; + let instance = wgpu::util::new_instance_with_webgpu_detection( + wgpu::InstanceDescriptor::new_without_display_handle(), + ) + .await; - // match Self::try_request_device(&instance, None).await { - // Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)), - // Err(_) => {} - // } + match Self::try_request_device(&instance, None).await { + Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)), + Err(_) => {} + } let (gl_instance, adapter, device, queue, canvas) = Self::try_gl_fallback().await?; Ok((gl_instance, adapter, device, queue, Some(canvas))) } @@ -369,6 +380,14 @@ impl GpuContext { height: u32, ) -> Result<(), GpuError> { self.configure_surface(surface, width, height)?; + self.present_texture_to_surface(texture, surface) + } + + pub fn present_texture_to_surface( + &self, + texture: &wgpu::Texture, + surface: &wgpu::Surface<'_>, + ) -> Result<(), GpuError> { let surface_texture = self.acquire_surface_texture(surface)?; let target_view = surface_texture .texture @@ -390,16 +409,38 @@ impl GpuContext { width: u32, height: u32, ) -> Result<(), GpuError> { - let Some(config) = surface.get_default_config(&self.adapter, width, height) else { - return Err(GpuError::UnsupportedSurfaceFormat); - }; - if config.format != self.texture_format { - return Err(GpuError::UnsupportedSurfaceFormat); - } + let config = self.build_surface_configuration(surface, width, height)?; surface.configure(&self.device, &config); Ok(()) } + fn build_surface_configuration( + &self, + surface: &wgpu::Surface<'_>, + width: u32, + height: u32, + ) -> Result { + let caps = surface.get_capabilities(&self.adapter); + if !caps.formats.contains(&self.texture_format) { + return Err(GpuError::UnsupportedSurfaceFormat); + } + + Ok(wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: self.texture_format, + width, + height, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto), + view_formats: vec![], + desired_maximum_frame_latency: 2, + }) + } + pub fn acquire_surface_texture( &self, surface: &wgpu::Surface<'_>, @@ -593,57 +634,29 @@ impl GpuContext { gl_canvas.set_width(width); gl_canvas.set_height(height); - let surface = self - .instance - .create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?; - - let caps = surface.get_capabilities(&self.adapter); - let surface_format = if caps.formats.contains(&self.texture_format) { - self.texture_format - } else if !caps.formats.is_empty() { - caps.formats[0] - } else { - return Err(GpuError::UnsupportedSurfaceFormat); + let mut cached_surface = self.gl_surface.borrow_mut(); + let cached_surface = match cached_surface.as_mut() { + Some(cached_surface) => cached_surface, + None => { + let surface = self + .instance + .create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?; + cached_surface.replace(CachedCanvasSurface { + surface, + size: (0, 0), + }); + cached_surface + .as_mut() + .expect("gl_surface cache should exist after initialization") + } }; - if surface_format != self.texture_format { - return Err(GpuError::UnsupportedSurfaceFormat); + if cached_surface.size != (width, height) { + self.configure_surface(&cached_surface.surface, width, height)?; + cached_surface.size = (width, height); } - let config = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format: surface_format, - width, - height, - present_mode: wgpu::PresentMode::Fifo, - alpha_mode: caps - .alpha_modes - .first() - .copied() - .unwrap_or(wgpu::CompositeAlphaMode::Auto), - view_formats: vec![], - desired_maximum_frame_latency: 2, - }; - surface.configure(&self.device, &config); - - let surface_texture = self.acquire_surface_texture(&surface)?; - let surface_view = surface_texture - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); - - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("gpu-gl-canvas-blit-encoder"), - }); - self.encode_texture_blit_to_view( - &mut encoder, - texture, - &surface_view, - "gpu-gl-canvas-blit", - ); - self.queue.submit([encoder.finish()]); - surface_texture.present(); + self.present_texture_to_surface(texture, &cached_surface.surface)?; Ok(gl_canvas) } diff --git a/rust/wasm/src/compositor.rs b/rust/wasm/src/compositor.rs index 84467842..7652b3f3 100644 --- a/rust/wasm/src/compositor.rs +++ b/rust/wasm/src/compositor.rs @@ -16,6 +16,8 @@ use crate::perf; struct CompositorRuntime { canvas: web_sys::HtmlCanvasElement, compositor: Compositor, + surface: wgpu::Surface<'static>, + surface_size: (u32, u32), } thread_local! { @@ -44,9 +46,23 @@ pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> { canvas.set_height(height); let compositor = Compositor::new(&gpu_runtime.context); + let surface = gpu_runtime + .context + .instance() + .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + gpu_runtime + .context + .configure_surface(&surface, width, height) + .map_err(|error| JsValue::from_str(&error.to_string()))?; COMPOSITOR_RUNTIME.with(|runtime| { - runtime.replace(Some(CompositorRuntime { canvas, compositor })); + runtime.replace(Some(CompositorRuntime { + canvas, + compositor, + surface, + surface_size: (width, height), + })); }); Ok(()) @@ -55,16 +71,25 @@ pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> { #[wasm_bindgen(js_name = resizeCompositor)] pub fn resize_compositor(width: u32, height: u32) -> Result<(), JsValue> { - COMPOSITOR_RUNTIME.with(|runtime| { - let mut borrow = runtime.borrow_mut(); - let Some(runtime) = borrow.as_mut() else { - return Err(JsValue::from_str( - "Compositor is not initialized. Call initCompositor() first.", - )); - }; - runtime.canvas.set_width(width); - runtime.canvas.set_height(height); - Ok(()) + with_gpu_runtime(|gpu_runtime| { + COMPOSITOR_RUNTIME.with(|runtime| { + let mut borrow = runtime.borrow_mut(); + let Some(runtime) = borrow.as_mut() else { + return Err(JsValue::from_str( + "Compositor is not initialized. Call initCompositor() first.", + )); + }; + runtime.canvas.set_width(width); + runtime.canvas.set_height(height); + if runtime.surface_size != (width, height) { + gpu_runtime + .context + .configure_surface(&runtime.surface, width, height) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + runtime.surface_size = (width, height); + } + Ok(()) + }) }) } @@ -144,15 +169,19 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> { )); }; - if gpu_runtime.context.supports_surface_rendering() { + if runtime.surface_size != (frame.width, frame.height) { + runtime.canvas.set_width(frame.width); + runtime.canvas.set_height(frame.height); let t_surface = perf::now_ms(); - let surface = gpu_runtime + gpu_runtime .context - .instance() - .create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone())) + .configure_surface(&runtime.surface, frame.width, frame.height) .map_err(|error| JsValue::from_str(&error.to_string()))?; - perf::record("wasm.surfaceCreate", perf::now_ms() - t_surface); + perf::record("wasm.surfaceConfigure", perf::now_ms() - t_surface); + runtime.surface_size = (frame.width, frame.height); + } + if gpu_runtime.context.supports_surface_rendering() { let t_render = perf::now_ms(); let result = runtime .compositor @@ -160,17 +189,15 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> { &gpu_runtime.context, RenderFrameOptions { frame: &frame, - surface: &surface, + surface: &runtime.surface, }, ) .map_err(|error| JsValue::from_str(&error.to_string())); perf::record("wasm.renderFrameToSurface", perf::now_ms() - t_render); result } else { - // WebGL surface-renders to the canvas its GL context was created - // on; `init_compositor` already pointed `runtime.canvas` at that - // same canvas, so presenting writes directly to the canvas the - // UI mounts. No intermediate copy. + // WebGL still needs a separate composition pass, but the output + // surface is now persistent just like the WebGPU path. let t_composite = perf::now_ms(); let texture = runtime .compositor @@ -181,9 +208,9 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> { let t_present = perf::now_ms(); gpu_runtime .context - .render_texture_to_gl_canvas_surface(&texture, frame.width, frame.height) + .present_texture_to_surface(&texture, &runtime.surface) .map_err(|error| JsValue::from_str(&error.to_string()))?; - perf::record("wasm.presentToGlCanvas", perf::now_ms() - t_present); + perf::record("wasm.presentToSurface", perf::now_ms() - t_present); Ok(()) } From eea6d43c82e1fd09d94d73001090dad205b1f1d2 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 02:50:04 +0200 Subject: [PATCH 04/29] fix: model editor time as wasm MediaTime ticks end-to-end --- apps/web/src/actions/use-editor-actions.ts | 92 +++++-- apps/web/src/animation/curve-bridge.ts | 5 +- apps/web/src/animation/interpolation.ts | 17 +- apps/web/src/animation/keyframes.ts | 62 +++-- apps/web/src/animation/types.ts | 7 +- apps/web/src/clipboard/handlers/keyframes.ts | 6 +- apps/web/src/clipboard/types.ts | 5 +- apps/web/src/commands/scene/move-bookmark.ts | 5 +- .../web/src/commands/scene/remove-bookmark.ts | 5 +- .../web/src/commands/scene/toggle-bookmark.ts | 5 +- .../web/src/commands/scene/update-bookmark.ts | 3 +- .../timeline/clipboard/paste-keyframes.ts | 24 +- .../src/commands/timeline/clipboard/paste.ts | 45 ++- .../timeline/element/duplicate-elements.ts | 3 +- .../timeline/element/insert-element.ts | 8 +- .../element/keyframes/retime-keyframe.ts | 15 +- .../keyframes/upsert-effect-param-keyframe.ts | 15 +- .../element/keyframes/upsert-keyframe.ts | 15 +- .../timeline/element/split-elements.ts | 64 +++-- apps/web/src/components/editable-timecode.tsx | 16 +- .../editor/panels/assets/draggable-item.tsx | 3 +- .../editor/panels/assets/views/assets.tsx | 6 +- .../properties/hooks/use-element-playhead.ts | 17 +- .../hooks/use-keyframed-color-property.ts | 3 +- .../hooks/use-keyframed-number-property.ts | 3 +- .../hooks/use-keyframed-param-property.ts | 3 +- .../src/core/managers/clipboard-manager.ts | 5 +- .../web/src/core/managers/playback-manager.ts | 36 ++- apps/web/src/core/managers/scenes-manager.ts | 19 +- .../web/src/core/managers/timeline-manager.ts | 25 +- .../src/graphics/components/graphic-tab.tsx | 4 +- apps/web/src/media/use-paste-media.ts | 4 +- apps/web/src/preview/components/toolbar.tsx | 5 +- apps/web/src/project/types.ts | 5 +- .../src/rendering/components/blending-tab.tsx | 5 +- apps/web/src/services/renderer/resolve.ts | 6 +- .../migrations/__tests__/v27-to-v28.test.ts | 257 ++++++++++++++++++ .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v27-to-v28.ts | 228 ++++++++++++++++ .../services/storage/migrations/v27-to-v28.ts | 14 + apps/web/src/services/storage/service.ts | 31 ++- .../subtitles/build-subtitle-text-element.ts | 6 +- apps/web/src/text/components/assets-view.tsx | 3 +- .../__tests__/update-pipeline.test.ts | 66 +++++ .../bookmarks/components/bookmarks.tsx | 41 ++- .../bookmarks/hooks/use-bookmark-drag.ts | 46 ++-- .../bookmarks/preview-overlay-source.tsx | 5 +- apps/web/src/timeline/bookmarks/utils.ts | 31 ++- .../src/timeline/components/drop-target.ts | 33 ++- apps/web/src/timeline/components/index.tsx | 3 +- .../timeline/components/timeline-element.tsx | 7 +- .../timeline/components/timeline-playhead.tsx | 32 ++- apps/web/src/timeline/creation.ts | 6 +- apps/web/src/timeline/defaults.ts | 9 +- apps/web/src/timeline/drag-utils.ts | 8 +- apps/web/src/timeline/element-utils.ts | 63 ++--- .../src/timeline/group-move/build-group.ts | 6 +- .../src/timeline/group-move/resolve-move.ts | 71 +++-- apps/web/src/timeline/group-move/snap.ts | 31 ++- apps/web/src/timeline/group-move/types.ts | 7 +- .../timeline/group-resize/compute-resize.ts | 30 +- .../hooks/element/use-element-interaction.ts | 70 +++-- .../hooks/element/use-keyframe-drag.ts | 57 ++-- .../timeline/hooks/use-timeline-drag-drop.ts | 14 +- .../timeline/hooks/use-timeline-playhead.ts | 27 +- .../src/timeline/hooks/use-timeline-resize.ts | 4 +- .../src/timeline/hooks/use-timeline-seek.ts | 12 +- .../src/timeline/hooks/use-timeline-zoom.ts | 7 +- apps/web/src/timeline/index.ts | 61 +++-- .../placement/__tests__/resolve.test.ts | 39 +-- apps/web/src/timeline/placement/main-track.ts | 9 +- apps/web/src/timeline/placement/resolve.ts | 3 +- apps/web/src/timeline/placement/types.ts | 7 +- apps/web/src/timeline/scenes.ts | 5 +- apps/web/src/timeline/types.ts | 31 ++- apps/web/src/timeline/update-pipeline.ts | 16 +- apps/web/src/wasm/index.ts | 2 +- apps/web/src/wasm/media-time.ts | 129 +++++++++ apps/web/src/wasm/ticks.ts | 3 - 79 files changed, 1589 insertions(+), 511 deletions(-) create mode 100644 apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts create mode 100644 apps/web/src/services/storage/migrations/v27-to-v28.ts create mode 100644 apps/web/src/timeline/__tests__/update-pipeline.test.ts create mode 100644 apps/web/src/wasm/media-time.ts delete mode 100644 apps/web/src/wasm/ticks.ts diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index e62a8ce0..5e90a6a1 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -5,7 +5,16 @@ import { useTimelineStore } from "@/timeline/timeline-store"; import { useActionHandler } from "@/actions/use-action-handler"; import { useEditor } from "@/editor/use-editor"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + mediaTimeFromSeconds, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; import { getElementsAtTime, hasMediaId } from "@/timeline"; import { cancelInteraction } from "@/editor/cancel-interaction"; @@ -83,7 +92,7 @@ export function useEditorActions() { if (editor.playback.getIsPlaying()) { editor.playback.toggle(); } - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); @@ -92,11 +101,15 @@ export function useEditorActions() { "seek-forward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -106,8 +119,15 @@ export function useEditorActions() { "seek-backward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -117,15 +137,20 @@ 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() + ticksPerFrame, + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, ), }); + editor.playback.seek({ + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), + }); }, undefined, ); @@ -134,11 +159,19 @@ export function useEditorActions() { "frame-step-backward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), }); }, undefined, @@ -148,11 +181,15 @@ export function useEditorActions() { "jump-forward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -162,8 +199,15 @@ export function useEditorActions() { "jump-backward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -172,7 +216,7 @@ export function useEditorActions() { useActionHandler( "goto-start", () => { - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts index af411dd1..c947e7dc 100644 --- a/apps/web/src/animation/curve-bridge.ts +++ b/apps/web/src/animation/curve-bridge.ts @@ -7,6 +7,7 @@ import type { NormalizedCubicBezier, ScalarAnimationKey, } from "@/animation/types"; +import { roundMediaTime } from "@/wasm"; const VALUE_EPSILON = 1e-6; @@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({ return { rightHandle: { - dt: spanTime * x1, + dt: roundMediaTime({ time: spanTime * x1 }), dv: effectiveSpanValue * y1, }, leftHandle: { - dt: spanTime * (x2 - 1), + dt: roundMediaTime({ time: spanTime * (x2 - 1) }), dv: effectiveSpanValue * (y2 - 1), }, }; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts index 277bfbb7..2169024a 100644 --- a/apps/web/src/animation/interpolation.ts +++ b/apps/web/src/animation/interpolation.ts @@ -9,6 +9,7 @@ import type { ScalarSegmentType, } from "@/animation/types"; import { clamp } from "@/utils/math"; +import { mediaTime } from "@/wasm"; import { getBezierPoint, getDefaultLeftHandle, @@ -63,9 +64,13 @@ function normalizeRightHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.min(span, Math.max(0, handle.dt)), + dt: mediaTime({ + ticks: Math.min(span, Math.max(0, handle.dt)), + }), dv: handle.dv, }; } @@ -83,9 +88,13 @@ function normalizeLeftHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.max(-span, Math.min(0, handle.dt)), + dt: mediaTime({ + ticks: Math.max(-span, Math.min(0, handle.dt)), + }), dv: handle.dv, }; } diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index 12dc777d..bdfe7c4c 100644 --- a/apps/web/src/animation/keyframes.ts +++ b/apps/web/src/animation/keyframes.ts @@ -35,6 +35,12 @@ import { coerceAnimationValueForProperty, getAnimationPropertyDefinition, } from "./property-registry"; +import { + type MediaTime, + roundMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; function isNearlySameTime({ leftTime, @@ -171,7 +177,7 @@ function createScalarKey({ previousKey, }: { id: string; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; previousKey?: ScalarAnimationKey; @@ -195,7 +201,7 @@ function createDiscreteKey({ value, }: { id: string; - time: number; + time: MediaTime; value: string | boolean; }): DiscreteAnimationKey { return { @@ -241,7 +247,7 @@ function getTargetKeyMetadata({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; + time: MediaTime; keyframeId?: string; }) { const normalizedChannel = @@ -280,7 +286,7 @@ function upsertDiscreteChannelKey({ keyframeId, }: { channel: DiscreteAnimationChannel | undefined; - time: number; + time: MediaTime; value: string | boolean; keyframeId?: string; }): DiscreteAnimationChannel { @@ -337,7 +343,7 @@ function upsertScalarChannelKey({ keyframeId, }: { channel: ScalarAnimationChannel | undefined; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; defaultInterpolation?: AnimationInterpolation; @@ -442,7 +448,7 @@ export function upsertPathKeyframe({ }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -537,7 +543,7 @@ export function upsertElementKeyframe({ }: { animations: ElementAnimations | undefined; propertyPath: AnimationPropertyPath; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -576,7 +582,7 @@ export function upsertKeyframe({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -642,7 +648,7 @@ export function retimeKeyframe({ }: { channel: AnimationChannel | undefined; keyframeId: string; - time: number; + time: MediaTime; }): AnimationChannel | undefined { if (!channel) { return undefined; @@ -887,7 +893,7 @@ export function clampAnimationsToDuration({ duration, }: { animations: ElementAnimations | undefined; - duration: number; + duration: MediaTime; }): ElementAnimations | undefined { if (!animations || duration <= 0) { return undefined; @@ -923,7 +929,7 @@ function splitDiscreteChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: DiscreteAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -939,7 +945,10 @@ function splitDiscreteChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); if (shouldIncludeSplitBoundary) { const hasBoundaryOnLeft = leftKeys.some((key) => @@ -967,7 +976,7 @@ function splitDiscreteChannelAtTime({ rightKeys = [ createDiscreteKey({ id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue as string | boolean, }), ...rightKeys, @@ -993,7 +1002,7 @@ function splitScalarChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: ScalarAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -1009,7 +1018,10 @@ function splitScalarChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); const hasBoundaryOnLeft = leftKeys.some((key) => isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), @@ -1089,7 +1101,7 @@ function splitScalarChannelAtTime({ { ...leftKey, rightHandle: { - dt: q0.x - p0.x, + dt: roundMediaTime({ time: q0.x - p0.x }), dv: q0.y - p0.y, }, }, @@ -1098,7 +1110,7 @@ function splitScalarChannelAtTime({ time: splitTime, value: boundaryValue, leftHandle: { - dt: r0.x - splitPoint.x, + dt: roundMediaTime({ time: r0.x - splitPoint.x }), dv: r0.y - splitPoint.y, }, segmentToNext: leftKey.segmentToNext, @@ -1108,10 +1120,10 @@ function splitScalarChannelAtTime({ rightKeys = [ { id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, rightHandle: { - dt: r1.x - splitPoint.x, + dt: roundMediaTime({ time: r1.x - splitPoint.x }), dv: r1.y - splitPoint.y, }, segmentToNext: "bezier", @@ -1119,9 +1131,9 @@ function splitScalarChannelAtTime({ }, { ...rightKey, - time: rightKey.time - splitTime, + time: subMediaTime({ a: rightKey.time, b: splitTime }), leftHandle: { - dt: q2.x - p3.x, + dt: roundMediaTime({ time: q2.x - p3.x }), dv: q2.y - p3.y, }, }, @@ -1129,7 +1141,7 @@ function splitScalarChannelAtTime({ .filter((key) => key.time > rightKey.time) .map((key) => ({ ...key, - time: key.time - splitTime, + time: subMediaTime({ a: key.time, b: splitTime }), })), ]; } else { @@ -1145,7 +1157,7 @@ function splitScalarChannelAtTime({ rightKeys = [ createScalarKey({ id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, interpolation: getScalarSegmentInterpolation({ segment: leftKey.segmentToNext, @@ -1201,7 +1213,7 @@ export function splitAnimationsAtTime({ shouldIncludeSplitBoundary = true, }: { animations: ElementAnimations | undefined; - splitTime: number; + splitTime: MediaTime; shouldIncludeSplitBoundary?: boolean; }): { leftAnimations: ElementAnimations | undefined; @@ -1317,7 +1329,7 @@ export function retimeElementKeyframe({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): ElementAnimations | undefined { const binding = getBinding({ animations, propertyPath }); if (!binding) { diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index 3afddfa1..e02c8ba8 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,4 +1,5 @@ import type { ParamValues } from "@/params"; +import type { MediaTime } from "@/wasm"; export const ANIMATION_PROPERTY_PATHS = [ "transform.positionX", @@ -77,13 +78,13 @@ export type TangentMode = "auto" | "aligned" | "broken" | "flat"; export type ChannelExtrapolationMode = "hold" | "linear"; export interface CurveHandle { - dt: number; + dt: MediaTime; dv: number; } interface BaseAnimationKeyframe { id: string; - time: number; // relative to element start time + time: MediaTime; // relative to element start time value: TValue; } @@ -209,7 +210,7 @@ export interface ScalarCurveKeyframePatch { export interface ElementKeyframe { propertyPath: AnimationPath; id: string; - time: number; + time: MediaTime; value: AnimationValue; interpolation: AnimationInterpolation; } diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts index ad156e12..cb0c0e65 100644 --- a/apps/web/src/clipboard/handlers/keyframes.ts +++ b/apps/web/src/clipboard/handlers/keyframes.ts @@ -7,6 +7,7 @@ import type { KeyframeClipboardCurvePatch, KeyframeClipboardItem, } from "../types"; +import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm"; function resolveSingleSourceElement({ selectedKeyframes, @@ -80,7 +81,7 @@ function buildClipboardItem({ element: TimelineElement; propertyPath: KeyframeClipboardItem["propertyPath"]; keyframeId: string; -}): (Omit & { time: number }) | null { +}): (Omit & { time: MediaTime }) | null { const keyframe = getKeyframeById({ animations: element.animations, propertyPath, @@ -136,10 +137,11 @@ export const KeyframesClipboardHandler = { } const minTime = Math.min(...rawItems.map((item) => item.time)); + const minTimeMedia = roundMediaTime({ time: minTime }); const items = rawItems .map(({ time, ...item }) => ({ ...item, - timeOffset: time - minTime, + timeOffset: subMediaTime({ a: time, b: minTimeMedia }), })) .sort( (left, right) => diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts index 0d249c9d..562f9ac7 100644 --- a/apps/web/src/clipboard/types.ts +++ b/apps/web/src/clipboard/types.ts @@ -12,6 +12,7 @@ import type { ElementRef, TrackType, } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface ElementClipboardItem { trackId: string; @@ -26,7 +27,7 @@ export interface KeyframeClipboardCurvePatch { export interface KeyframeClipboardItem { propertyPath: AnimationPath; - timeOffset: number; + timeOffset: MediaTime; value: AnimationValue; interpolation: AnimationInterpolation; curvePatches: KeyframeClipboardCurvePatch[]; @@ -61,7 +62,7 @@ export interface PasteContext { editor: EditorCore; selectedElements: ElementRef[]; selectedKeyframes: SelectedKeyframeRef[]; - time: number; + time: MediaTime; } export interface ClipboardHandler { diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts index a1624eb0..f3628b28 100644 --- a/apps/web/src/commands/scene/move-bookmark.ts +++ b/apps/web/src/commands/scene/move-bookmark.ts @@ -3,13 +3,14 @@ import { EditorCore } from "@/core"; import type { TScene } from "@/timeline"; import { updateSceneInArray } from "@/timeline/scenes"; import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class MoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; constructor( - private fromTime: number, - private toTime: number, + private fromTime: MediaTime, + private toTime: MediaTime, ) { super(); } diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts index ea592556..bff5c035 100644 --- a/apps/web/src/commands/scene/remove-bookmark.ts +++ b/apps/web/src/commands/scene/remove-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, removeBookmarkFromArray, } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class RemoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: number = 0; + private frameTime: MediaTime = 0 as MediaTime; - constructor(private time: number) { + constructor(private time: MediaTime) { super(); } diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts index 4193c76e..9f318f80 100644 --- a/apps/web/src/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/commands/scene/toggle-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, toggleBookmarkInArray, } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class ToggleBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: number = 0; + private frameTime: MediaTime = 0 as MediaTime; - constructor(private time: number) { + constructor(private time: MediaTime) { super(); } diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts index 0d6a0193..238bb3de 100644 --- a/apps/web/src/commands/scene/update-bookmark.ts +++ b/apps/web/src/commands/scene/update-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, updateBookmarkInArray, } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class UpdateBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; constructor( - private time: number, + private time: MediaTime, private updates: Partial>, ) { super(); diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts index e05b49fa..c1127921 100644 --- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -10,6 +10,13 @@ import type { KeyframeClipboardItem } from "@/clipboard"; import type { SceneTracks, TimelineElement } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline"; import { generateUUID } from "@/utils/id"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; function pasteKeyframesIntoElement({ element, @@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({ clipboardItems, }: { element: TimelineElement; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }): TimelineElement { let nextElement = element; @@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({ continue; } - const keyframeTime = Math.max( - 0, - Math.min(time + item.timeOffset, nextElement.duration), - ); + const keyframeTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ + a: addMediaTime({ a: time, b: item.timeOffset }), + b: nextElement.duration, + }), + }); const nextAnimations = upsertPathKeyframe({ animations: nextElement.animations, propertyPath: item.propertyPath, @@ -79,7 +89,7 @@ export class PasteKeyframesCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: KeyframeClipboardItem[]; constructor({ @@ -90,7 +100,7 @@ export class PasteKeyframesCommand extends Command { }: { trackId: string; elementId: string; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }) { super(); diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts index 4523566e..be0c2b91 100644 --- a/apps/web/src/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/commands/timeline/clipboard/paste.ts @@ -13,18 +13,25 @@ import { enforceMainTrackStart, } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class PasteCommand extends Command { private savedState: SceneTracks | null = null; private pastedElements: { trackId: string; elementId: string }[] = []; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: ElementClipboardItem[]; constructor({ time, clipboardItems, }: { - time: number; + time: MediaTime; clipboardItems: ElementClipboardItem[]; }) { super(); @@ -39,8 +46,12 @@ export class PasteCommand extends Command { this.savedState = editor.scenes.getActiveScene().tracks; this.pastedElements = []; - const minStart = Math.min( - ...this.clipboardItems.map((item) => item.element.startTime), + const minStart = this.clipboardItems.reduce( + (earliestStartTime, item) => + item.element.startTime < earliestStartTime + ? item.element.startTime + : earliestStartTime, + this.clipboardItems[0].element.startTime, ); let updatedTracks = this.savedState; @@ -97,12 +108,18 @@ export class PasteCommand extends Command { targetTrackId: targetTrack.id, requestedStartTime: earliestElement.startTime, }); - const delta = adjustedEarliestStartTime - earliestElement.startTime; + const delta = subMediaTime({ + a: adjustedEarliestStartTime, + b: earliestElement.startTime, + }); - if (delta !== 0) { + if (delta !== ZERO_MEDIA_TIME) { elementsForPlacement = elementsToAdd.map((element) => ({ ...element, - startTime: Math.max(0, element.startTime + delta), + startTime: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: element.startTime, b: delta }), + }), })); } } @@ -168,14 +185,20 @@ function buildPastedElements({ time, }: { items: ElementClipboardItem[]; - minStart: number; - time: number; + minStart: MediaTime; + time: MediaTime; }): TimelineElement[] { const elementsToAdd: TimelineElement[] = []; for (const item of items) { - const relativeOffset = item.element.startTime - minStart; - const startTime = Math.max(0, time + relativeOffset); + const relativeOffset = subMediaTime({ + a: item.element.startTime, + b: minStart, + }); + const startTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: time, b: relativeOffset }), + }); const newElementId = generateUUID(); elementsToAdd.push({ diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts index 8f65f7c7..2b1ab4ea 100644 --- a/apps/web/src/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts @@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import type { MediaTime } from "@/wasm"; interface DuplicateElementsParams { elements: { trackId: string; elementId: string }[]; @@ -119,7 +120,7 @@ function buildDuplicateElement({ }: { element: TimelineElement; id: string; - startTime: number; + startTime: MediaTime; }): TimelineElement { return { ...element, diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts index 49ac6f2d..2110d67b 100644 --- a/apps/web/src/commands/timeline/element/insert-element.ts +++ b/apps/web/src/commands/timeline/element/insert-element.ts @@ -18,6 +18,7 @@ import { resolveTrackPlacement, validateElementTrackCompatibility, } from "@/timeline/placement"; +import { roundMediaTime } from "@/wasm"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -266,7 +267,12 @@ export class InsertElementCommand extends Command { placementResult.kind === "existingTrack" ? { ...element, - startTime: placementResult.adjustedStartTime ?? element.startTime, + startTime: + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ + time: placementResult.adjustedStartTime, + }) + : element.startTime, } : element; diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts index ccbdbc5d..9337e02d 100644 --- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class RetimeKeyframeCommand extends Command { private savedState: SceneTracks | null = null; @@ -11,7 +17,7 @@ export class RetimeKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; - private readonly nextTime: number; + private readonly nextTime: MediaTime; constructor({ trackId, @@ -24,7 +30,7 @@ export class RetimeKeyframeCommand extends Command { elementId: string; propertyPath: AnimationPath; keyframeId: string; - nextTime: number; + nextTime: MediaTime; }) { super(); this.trackId = trackId; @@ -47,7 +53,10 @@ export class RetimeKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.nextTime, b: element.duration }), + }); return { ...element, animations: retimeElementKeyframe({ diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index eae8f039..91ddb23d 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -9,6 +9,12 @@ import { updateElementInSceneTracks } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; import type { AnimationInterpolation } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertEffectParamKeyframeCommand extends Command { private savedState: SceneTracks | null = null; @@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { private readonly elementId: string; private readonly effectId: string; private readonly paramKey: string; - private readonly time: number; + private readonly time: MediaTime; private readonly value: number | string | boolean; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number | string | boolean; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: this.elementId, elementPredicate: isVisualElement, update: (element) => { - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); const propertyPath = buildEffectParamPath({ effectId: this.effectId, paramKey: this.paramKey, diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts index 30514146..66f0c434 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -8,13 +8,19 @@ import type { AnimationInterpolation, AnimationValue, } from "@/animation/types"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertKeyframeCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; - private readonly time: number; + private readonly time: MediaTime; private readonly value: AnimationValue; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -31,7 +37,7 @@ export class UpsertKeyframeCommand extends Command { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -63,7 +69,10 @@ export class UpsertKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); return { ...element, animations: upsertPathKeyframe({ diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts index 11c317f4..0a551fc3 100644 --- a/apps/web/src/commands/timeline/element/split-elements.ts +++ b/apps/web/src/commands/timeline/element/split-elements.ts @@ -9,12 +9,18 @@ import { EditorCore } from "@/core"; import { isRetimableElement } from "@/timeline"; import { splitAnimationsAtTime } from "@/animation"; import { getSourceSpanAtClipTime } from "@/retime"; +import { + addMediaTime, + type MediaTime, + roundMediaTime, + subMediaTime, +} from "@/wasm"; export class SplitElementsCommand extends Command { private savedState: SceneTracks | null = null; private rightSideElements: { trackId: string; elementId: string }[] = []; private readonly elements: { trackId: string; elementId: string }[]; - private readonly splitTime: number; + private readonly splitTime: MediaTime; private readonly retainSide: "both" | "left" | "right"; constructor({ @@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }) { super(); @@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command { return [element]; } - const relativeTime = this.splitTime - element.startTime; + const relativeTime = subMediaTime({ + a: this.splitTime, + b: element.startTime, + }); const leftVisibleDuration = relativeTime; - const rightVisibleDuration = element.duration - relativeTime; + const rightVisibleDuration = subMediaTime({ + a: element.duration, + b: relativeTime, + }); const retimeRef = isRetimableElement(element) ? element.retime : undefined; - const leftSourceSpan = getSourceSpanAtClipTime({ - clipTime: leftVisibleDuration, - retime: retimeRef, + // Snap the source-side split point exactly once and derive the right + // half from it. Independently rounding both spans (left and total) + // would let a 1-tick rounding error desynchronise them, breaking the + // invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`. + // See the same discipline in `compute-resize.ts` (snap-once comment). + const leftSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: leftVisibleDuration, + retime: retimeRef, + }), }); - const totalSourceSpan = getSourceSpanAtClipTime({ - clipTime: element.duration, - retime: retimeRef, + const totalSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: element.duration, + retime: retimeRef, + }), + }); + const rightSourceSpan = subMediaTime({ + a: totalSourceSpan, + b: leftSourceSpan, }); - const rightSourceSpan = totalSourceSpan - leftSourceSpan; const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ animations: element.animations, splitTime: relativeTime, @@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command { }); let splitResult: TimelineElement[]; + const leftTrimEnd = addMediaTime({ + a: element.trimEnd, + b: rightSourceSpan, + }); + const rightTrimStart = addMediaTime({ + a: element.trimStart, + b: leftSourceSpan, + }); + if (this.retainSide === "left") { splitResult = [ { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command { id: newId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, ]; } else { - // "both" - split into two pieces const secondElementId = generateUUID(); this.rightSideElements.push({ trackId: track.id, @@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command { { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command { id: secondElementId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 276cb490..8450cec2 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -3,13 +3,14 @@ import { useEffect, useRef, useState } from "react"; import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; interface EditableTimecodeProps { - time: number; - duration: number; + time: MediaTime; + duration: MediaTime; format?: TimeCodeFormat; fps: FrameRate; - onTimeChange?: ({ time }: { time: number }) => void; + onTimeChange?: ({ time }: { time: MediaTime }) => void; className?: string; disabled?: boolean; } @@ -53,9 +54,12 @@ export function EditableTimecode({ return; } - const clampedTime = duration - ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime) - : parsedTime; + const clampedTime = ( + duration + ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? + parsedTime) + : parsedTime + ) as MediaTime; onTimeChange?.({ time: clampedTime }); setIsEditing(false); diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 2f8bc64a..fdbf5e76 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -14,13 +14,14 @@ import { useEditor } from "@/editor/use-editor"; import { clearDragData, setDragData } from "@/timeline/drag-data"; import type { TimelineDragData } from "@/timeline/drag"; import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; export interface DraggableItemProps { name: string; preview: ReactNode; dragData: TimelineDragData; onDragStart?: ({ e }: { e: React.DragEvent }) => void; - onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; + onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void; aspectRatio?: number; className?: string; containerClassName?: string; 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 4afd22b7..c071fdc7 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -26,7 +26,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { useFileUpload } from "@/media/use-file-upload"; import { invokeAction } from "@/actions"; @@ -255,11 +255,11 @@ function MediaAssetDraggable({ startTime, }: { asset: MediaAsset; - startTime: number; + startTime: MediaTime; }) => { const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: asset.id, 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 967156b3..83e31aec 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,22 +1,25 @@ import { useEditor } from "@/editor/use-editor"; import { getElementLocalTime } from "@/animation"; +import { addMediaTime, mediaTime, type MediaTime } from "@/wasm"; export function useElementPlayhead({ startTime, duration, }: { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; }) { const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, + const localTime = mediaTime({ + ticks: getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: startTime, + elementDuration: duration, + }), }); const isPlayheadWithinElementRange = playheadTime >= startTime && - playheadTime <= startTime + duration; + playheadTime <= addMediaTime({ a: startTime, b: duration }); return { localTime, isPlayheadWithinElementRange }; } diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index 734b514f..f6216208 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -6,6 +6,7 @@ import { } from "@/animation"; import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export function useKeyframedColorProperty({ trackId, @@ -21,7 +22,7 @@ export function useKeyframedColorProperty({ elementId: string; animations: ElementAnimations | undefined; propertyPath: AnimationPropertyPath; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedColor: string; buildBaseUpdates: ({ value }: { value: string }) => Partial; diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 9dd39768..d52f7d97 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -8,6 +8,7 @@ import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types import type { TimelineElement } from "@/timeline"; import { snapToStep } from "@/utils/math"; import { usePropertyDraft } from "./use-property-draft"; +import type { MediaTime } from "@/wasm"; export function useKeyframedNumberProperty({ trackId, @@ -27,7 +28,7 @@ export function useKeyframedNumberProperty({ elementId: string; animations: ElementAnimations | undefined; propertyPath: AnimationPropertyPath; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; displayValue: string; parse: (input: string) => number | null; diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index b0b6f01a..e679f5c3 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -15,6 +15,7 @@ import type { } from "@/animation/types"; import type { ParamDefinition } from "@/params"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface KeyframedParamPropertyResult { hasAnimatedKeyframes: boolean; @@ -39,7 +40,7 @@ export function useKeyframedParamProperty({ trackId: string; elementId: string; animations: ElementAnimations | undefined; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedValue: number | string | boolean; buildBaseUpdates: ({ diff --git a/apps/web/src/core/managers/clipboard-manager.ts b/apps/web/src/core/managers/clipboard-manager.ts index 8a2a51c9..283a78ae 100644 --- a/apps/web/src/core/managers/clipboard-manager.ts +++ b/apps/web/src/core/managers/clipboard-manager.ts @@ -6,6 +6,7 @@ import { type CopyContext, type PasteContext, } from "@/clipboard"; +import type { MediaTime } from "@/wasm"; export class ClipboardManager { private entry: ClipboardEntry | null = null; @@ -34,7 +35,7 @@ export class ClipboardManager { return true; } - paste({ time }: { time?: number } = {}): boolean { + paste({ time }: { time?: MediaTime } = {}): boolean { if (!this.entry) { return false; } @@ -64,7 +65,7 @@ export class ClipboardManager { }; } - private getPasteContext({ time }: { time?: number }): PasteContext { + private getPasteContext({ time }: { time?: MediaTime }): PasteContext { return { editor: this.editor, selectedElements: this.editor.selection.getSelectedElements(), diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 1ea09826..6748fd42 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -1,10 +1,16 @@ import type { EditorCore } from "@/core"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + clampMediaTime, + type MediaTime, + mediaTimeFromSeconds, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { roundToFrame } from "opencut-wasm"; export class PlaybackManager { private isPlaying = false; - private currentTime = 0; + private currentTime: MediaTime = ZERO_MEDIA_TIME; private volume = 1; private muted = false; private previousVolume = 1; @@ -12,7 +18,7 @@ export class PlaybackManager { private listeners = new Set<() => void>(); private playbackTimer: number | null = null; private playbackStartWallTime = 0; - private playbackStartTime = 0; + private playbackStartTime: MediaTime = ZERO_MEDIA_TIME; private timelineScopeBound = false; constructor(private editor: EditorCore) {} @@ -38,7 +44,7 @@ export class PlaybackManager { } if (this.currentTime >= maxTime) { - this.seek({ time: 0 }); + this.seek({ time: ZERO_MEDIA_TIME }); } this.isPlaying = true; @@ -60,7 +66,7 @@ export class PlaybackManager { } } - seek({ time }: { time: number }): void { + seek({ time }: { time: MediaTime }): void { this.currentTime = this.clampTimeToTimeline(time); if (this.isPlaying) { this.playbackStartWallTime = performance.now(); @@ -107,7 +113,7 @@ export class PlaybackManager { return this.isPlaying; } - getCurrentTime(): number { + getCurrentTime(): MediaTime { return this.currentTime; } @@ -185,11 +191,13 @@ export class PlaybackManager { const fps = this.editor.project.getActive()?.settings.fps; const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000; - const rawTime = - this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); - const newTime = fps + const rawTime = addMediaTime({ + a: this.playbackStartTime, + b: mediaTimeFromSeconds({ seconds: elapsedSeconds }), + }); + const newTime = (fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) - : rawTime; + : rawTime) as MediaTime; const maxTime = this.editor.timeline.getTotalDuration(); if (newTime >= maxTime) { @@ -205,12 +213,12 @@ export class PlaybackManager { this.playbackTimer = requestAnimationFrame(this.updateTime); }; - private clampTimeToTimeline(time: number): number { + private clampTimeToTimeline(time: MediaTime): MediaTime { const maxTime = this.editor.timeline.getTotalDuration(); - return Math.max(0, Math.min(maxTime, time)); + return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime }); } - private dispatchSeekEvent(time: number): void { + private dispatchSeekEvent(time: MediaTime): void { if (typeof window === "undefined") { return; } @@ -222,7 +230,7 @@ export class PlaybackManager { ); } - private dispatchUpdateEvent(time: number): void { + private dispatchUpdateEvent(time: MediaTime): void { if (typeof window === "undefined") { return; } diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 13f251a1..c9558a6d 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -1,5 +1,5 @@ import type { EditorCore } from "@/core"; -import type { SceneTracks, TScene } from "@/timeline"; +import type { Bookmark, SceneTracks, TScene } from "@/timeline"; import { storageService } from "@/services/storage/service"; import { getMainScene, @@ -21,6 +21,7 @@ import { ToggleBookmarkCommand, UpdateBookmarkCommand, } from "@/commands/scene"; +import type { MediaTime } from "@/wasm"; export class ScenesManager { private active: TScene | null = null; @@ -106,12 +107,12 @@ export class ScenesManager { this.notify(); } - async toggleBookmark({ time }: { time: number }): Promise { + async toggleBookmark({ time }: { time: MediaTime }): Promise { const command = new ToggleBookmarkCommand(time); this.editor.command.execute({ command }); } - isBookmarked({ time }: { time: number }): boolean { + isBookmarked({ time }: { time: MediaTime }): boolean { const activeScene = this.getActiveScene(); const activeProject = this.editor.project.getActive(); @@ -125,7 +126,7 @@ export class ScenesManager { return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime }); } - async removeBookmark({ time }: { time: number }): Promise { + async removeBookmark({ time }: { time: MediaTime }): Promise { const command = new RemoveBookmarkCommand(time); this.editor.command.execute({ command }); } @@ -134,8 +135,8 @@ export class ScenesManager { time, updates, }: { - time: number; - updates: Partial<{ note: string; color: string; duration: number }>; + time: MediaTime; + updates: Partial>; }): Promise { const command = new UpdateBookmarkCommand(time, updates); this.editor.command.execute({ command }); @@ -145,14 +146,14 @@ export class ScenesManager { fromTime, toTime, }: { - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Promise { const command = new MoveBookmarkCommand(fromTime, toTime); this.editor.command.execute({ command }); } - getBookmarkAtTime({ time }: { time: number }) { + getBookmarkAtTime({ time }: { time: MediaTime }) { const activeScene = this.active; const activeProject = this.editor.project.getActive(); diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index dbc3332b..a1ead6ca 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -10,6 +10,7 @@ import type { } from "@/timeline"; import { calculateTotalDuration } from "@/timeline"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import { canElementBeHidden, canElementHaveAudio, @@ -95,10 +96,10 @@ export class TimelineManager { pushHistory = true, }: { elementId: string; - trimStart: number; - trimEnd: number; - startTime?: number; - duration?: number; + trimStart: MediaTime; + trimEnd: MediaTime; + startTime?: MediaTime; + duration?: MediaTime; pushHistory?: boolean; }): void { const trackId = this.findTrackIdForElement({ elementId }); @@ -188,7 +189,7 @@ export class TimelineManager { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }): { trackId: string; elementId: string }[] { const command = new SplitElementsCommand({ @@ -200,20 +201,20 @@ export class TimelineManager { return command.getRightSideElements(); } - getTotalDuration(): number { + getTotalDuration(): MediaTime { const activeScene = this.editor.scenes.getActiveSceneOrNull(); if (!activeScene) { - return 0; + return ZERO_MEDIA_TIME; } return calculateTotalDuration({ tracks: activeScene.tracks }); } - getLastFrameTime(): number { + getLastFrameTime(): MediaTime { const duration = this.getTotalDuration(); const fps = this.editor.project.getActive()?.settings.fps; if (!fps || duration <= 0) return duration; - return lastFrameTime({ duration, rate: fps }) ?? duration; + return (lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime; } getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { @@ -483,7 +484,7 @@ export class TimelineManager { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; + time: MediaTime; value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -600,7 +601,7 @@ export class TimelineManager { elementId: string; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): void { const command = new RetimeKeyframeCommand({ trackId, @@ -658,7 +659,7 @@ export class TimelineManager { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number; interpolation?: "linear" | "hold"; keyframeId?: string; diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx index 60599772..549ef4b6 100644 --- a/apps/web/src/graphics/components/graphic-tab.tsx +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -24,6 +24,7 @@ import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; registerDefaultGraphics(); @@ -197,10 +198,11 @@ function AnimatedGraphicParamField({ isPlayheadWithinElementRange, resolvedParams, }: { + key?: string; param: ParamDefinition; trackId: string; element: GraphicElement; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedParams: ParamValues; }) { diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts index 3055920e..387b2b9a 100644 --- a/apps/web/src/media/use-paste-media.ts +++ b/apps/web/src/media/use-paste-media.ts @@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media"; import { InsertElementCommand } from "@/commands/timeline"; import { BatchCommand } from "@/commands"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds } from "@/wasm"; import { isTypableDOMElement } from "@/utils/browser"; import type { MediaType } from "@/media/types"; @@ -75,7 +75,7 @@ export function usePasteMedia() { const assetId = addMediaCmd.getAssetId(); const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const trackType = asset.type === "audio" ? "audio" : "video"; diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index b44e42ef..084c5b55 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -26,6 +26,7 @@ import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom"; import { usePreviewViewport } from "./preview-viewport"; import { GridPopover } from "./guide-popover"; import { usePreviewStore } from "@/preview/preview-store"; +import type { MediaTime } from "@/wasm"; export function PreviewToolbar({ onToggleFullscreen, @@ -67,13 +68,13 @@ function TimecodeDisplay() { const editor = useEditor(); const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); const fps = useEditor((e) => e.project.getActive().settings.fps); - const [currentTime, setCurrentTime] = useState(() => + const [currentTime, setCurrentTime] = useState(() => editor.playback.getCurrentTime(), ); useEffect(() => { const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); + setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time); window.addEventListener("playback-update", handler); window.addEventListener("playback-seek", handler); return () => { diff --git a/apps/web/src/project/types.ts b/apps/web/src/project/types.ts index 42e844f6..dca2d785 100644 --- a/apps/web/src/project/types.ts +++ b/apps/web/src/project/types.ts @@ -1,5 +1,6 @@ import type { FrameRate } from "opencut-wasm"; import type { TScene } from "@/timeline/types"; +import type { MediaTime } from "@/wasm"; export type TBackground = | { @@ -20,7 +21,7 @@ export interface TProjectMetadata { id: string; name: string; thumbnail?: string; - duration: number; + duration: MediaTime; createdAt: Date; updatedAt: Date; } @@ -37,7 +38,7 @@ export interface TProjectSettings { export interface TTimelineViewState { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; } export interface TProject { diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx index 3e0db46c..870390bf 100644 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ b/apps/web/src/rendering/components/blending-tab.tsx @@ -30,14 +30,15 @@ import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/ import { resolveOpacityAtTime } from "@/animation"; import { DEFAULTS } from "@/timeline/defaults"; import { isPropertyAtDefault } from "./transform-tab"; +import type { MediaTime } from "@/wasm"; type BlendingElement = { id: string; opacity: number; type: ElementType; blendMode?: BlendMode; - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; animations?: ElementAnimations; }; diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 5495ba3b..0623b399 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -1,4 +1,4 @@ -import { mediaTimeToSeconds } from "opencut-wasm"; +import { mediaTimeToSeconds, roundMediaTime } from "@/wasm"; import { getElementLocalTime, resolveColorAtTime, @@ -204,7 +204,7 @@ async function resolveVideoNode({ const frame = await videoCache.getFrameAt({ mediaId: node.params.mediaId, file: node.params.file, - time: mediaTimeToSeconds({ time: sourceTimeTicks }), + time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }), }); if (!frame) { return null; @@ -421,7 +421,7 @@ async function resolveBackdropSource({ const frame = await videoCache.getFrameAt({ mediaId: node.params.mediaId, file: node.params.file, - time: mediaTimeToSeconds({ time: sourceTimeTicks }), + time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }), }); if (!frame) { return null; diff --git a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts new file mode 100644 index 00000000..d467c8a1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV27ToV28 } from "../transformers/v27-to-v28"; + +describe("V27 to V28 Migration", () => { + test("rounds persisted media-time floats back to integer ticks", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v27-float-time", + version: 27, + metadata: { + id: "project-v27-float-time", + name: "Project", + duration: 2_152_466.3677130044, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + settings: { + fps: { numerator: 30, denominator: 1 }, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + timelineViewState: { + zoomLevel: 1.25, + scrollLeft: 120, + playheadTime: 301_234.8, + }, + scenes: [ + { + id: "scene-1", + name: "Scene 1", + isMain: true, + bookmarks: [ + { + time: 120_000.4, + duration: 60_000.6, + note: "Marker", + color: "#ff0000", + }, + ], + tracks: { + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [ + { + id: "element-1", + type: "video", + name: "Clip", + startTime: 300_000.49, + duration: 2_152_466.3677130044, + trimStart: 30_000.2, + trimEnd: 15_000.7, + sourceDuration: 2_197_467.6, + mediaId: "media-1", + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + animations: { + channels: { + opacity: { + kind: "scalar", + keys: [ + { + id: "key-1", + time: 1_000.6, + value: 1, + segmentToNext: "bezier", + tangentMode: "flat", + leftHandle: { dt: -120.6, dv: 0 }, + rightHandle: { dt: 60.4, dv: 0 }, + }, + ], + }, + }, + }, + }, + ], + }, + overlay: [], + audio: [], + }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(28); + + const metadata = result.project.metadata as Record; + expect(metadata.duration).toBe(2_152_466); + + const timelineViewState = result.project.timelineViewState as Record< + string, + unknown + >; + expect(timelineViewState.playheadTime).toBe(301_235); + expect(timelineViewState.zoomLevel).toBe(1.25); + expect(timelineViewState.scrollLeft).toBe(120); + + const scenes = result.project.scenes as Array>; + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([ + { + time: 120_000, + duration: 60_001, + note: "Marker", + color: "#ff0000", + }, + ]); + + const tracks = scene.tracks as Record; + const mainTrack = tracks.main as Record; + const elements = mainTrack.elements as Array>; + const element = elements[0]; + expect(element.startTime).toBe(300_000); + expect(element.duration).toBe(2_152_466); + expect(element.trimStart).toBe(30_000); + expect(element.trimEnd).toBe(15_001); + expect(element.sourceDuration).toBe(2_197_468); + + const animations = element.animations as Record; + const channels = animations.channels as Record>; + const opacityChannel = channels.opacity; + expect(opacityChannel.keys).toEqual([ + { + id: "key-1", + time: 1_001, + value: 1, + segmentToNext: "bezier", + tangentMode: "flat", + leftHandle: { dt: -121, dv: 0 }, + rightHandle: { dt: 60, dv: 0 }, + }, + ]); + }); + + test("keeps already-integer media-time values unchanged", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v27-integer-time", + version: 27, + metadata: { + id: "project-v27-integer-time", + name: "Project", + duration: 120_000, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + timelineViewState: { + zoomLevel: 2, + scrollLeft: 300, + playheadTime: 30_000, + }, + scenes: [ + { + id: "scene-1", + bookmarks: [{ time: 60_000, duration: 15_000 }], + tracks: { + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [ + { + id: "element-1", + type: "video", + name: "Clip", + startTime: 10_000, + duration: 20_000, + trimStart: 1_000, + trimEnd: 2_000, + sourceDuration: 23_000, + mediaId: "media-1", + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + overlay: [], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(28); + + const metadata = result.project.metadata as Record; + expect(metadata.duration).toBe(120_000); + + const timelineViewState = result.project.timelineViewState as Record< + string, + unknown + >; + expect(timelineViewState).toEqual({ + zoomLevel: 2, + scrollLeft: 300, + playheadTime: 30_000, + }); + + const scenes = result.project.scenes as Array>; + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]); + + const tracks = scene.tracks as Record; + const mainTrack = tracks.main as Record; + const element = (mainTrack.elements as Array>)[0]; + expect(element.startTime).toBe(10_000); + expect(element.duration).toBe(20_000); + expect(element.trimStart).toBe(1_000); + expect(element.trimEnd).toBe(2_000); + expect(element.sourceDuration).toBe(23_000); + }); + + test("skips projects already on v28", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v28", + version: 28, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v28"); + }); + + test("skips projects not on v27", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v26", + version: 26, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v27"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index b290b713..150da7c4 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -26,10 +26,11 @@ import { V23toV24Migration } from "./v23-to-v24"; import { V24toV25Migration } from "./v24-to-v25"; import { V25toV26Migration } from "./v25-to-v26"; import { V26toV27Migration } from "./v26-to-v27"; +import { V27toV28Migration } from "./v27-to-v28"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 27; +export const CURRENT_PROJECT_VERSION = 28; export const migrations = [ new V0toV1Migration(), @@ -59,4 +60,5 @@ export const migrations = [ new V24toV25Migration(), new V25toV26Migration(), new V26toV27Migration(), + new V27toV28Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts new file mode 100644 index 00000000..fc8d8432 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts @@ -0,0 +1,228 @@ +import { roundMediaTime } from "@/wasm"; +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV27ToV28({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 28) { + return { project, skipped: true, reason: "already v28" }; + } + if (version !== 27) { + return { project, skipped: true, reason: "not v27" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 28, + }, + skipped: false, + }; +} + +function migrateProject({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const nextProject = { ...project }; + + if (isRecord(project.metadata)) { + nextProject.metadata = migrateTimeFields({ + record: project.metadata, + keys: ["duration"], + }); + } + + if (isRecord(project.timelineViewState)) { + nextProject.timelineViewState = migrateTimeFields({ + record: project.timelineViewState, + keys: ["playheadTime"], + }); + } + + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + + if (Array.isArray(scene.bookmarks)) { + nextScene.bookmarks = scene.bookmarks.map((bookmark) => + migrateBookmark({ bookmark }), + ); + } + + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const nextElement = migrateTimeFields({ + record: element, + keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"], + }); + + if (isRecord(element.animations)) { + nextElement.animations = migrateAnimations({ + animations: element.animations, + }); + } + + return nextElement; +} + +function migrateAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord { + if (!isRecord(animations.channels)) { + return animations; + } + + return { + ...animations, + channels: Object.fromEntries( + Object.entries(animations.channels).map(([channelId, channel]) => [ + channelId, + migrateAnimationChannel({ channel }), + ]), + ), + }; +} + +function migrateAnimationChannel({ channel }: { channel: unknown }): unknown { + if (!isRecord(channel) || !Array.isArray(channel.keys)) { + return channel; + } + + return { + ...channel, + keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })), + }; +} + +function migrateAnimationKeyframe({ + keyframe, +}: { + keyframe: unknown; +}): unknown { + if (!isRecord(keyframe)) { + return keyframe; + } + + const nextKeyframe = migrateTimeFields({ + record: keyframe, + keys: ["time"], + }); + + if (isRecord(keyframe.leftHandle)) { + nextKeyframe.leftHandle = migrateTimeFields({ + record: keyframe.leftHandle, + keys: ["dt"], + }); + } + + if (isRecord(keyframe.rightHandle)) { + nextKeyframe.rightHandle = migrateTimeFields({ + record: keyframe.rightHandle, + keys: ["dt"], + }); + } + + return nextKeyframe; +} + +function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown { + if (!isRecord(bookmark)) { + return bookmark; + } + + return migrateTimeFields({ + record: bookmark, + keys: ["time", "duration"], + }); +} + +function migrateTimeFields({ + record, + keys, +}: { + record: ProjectRecord; + keys: string[]; +}): ProjectRecord { + const nextRecord = { ...record }; + + for (const key of keys) { + if (!(key in record)) { + continue; + } + + nextRecord[key] = normalizeMediaTimeValue({ value: record[key] }); + } + + return nextRecord; +} + +function normalizeMediaTimeValue({ value }: { value: unknown }): unknown { + if (typeof value !== "number" || !Number.isFinite(value)) { + return value; + } + return roundMediaTime({ time: value }); +} diff --git a/apps/web/src/services/storage/migrations/v27-to-v28.ts b/apps/web/src/services/storage/migrations/v27-to-v28.ts new file mode 100644 index 00000000..aaec6256 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v27-to-v28.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV27ToV28 } from "./transformers/v27-to-v28"; + +export class V27toV28Migration extends StorageMigration { + from = 27; + to = 28; + + async run({ + project, + }: StorageMigrationRunArgs): Promise> { + return transformProjectV27ToV28({ project }); + } +} diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index 858198eb..a388e807 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -22,12 +22,15 @@ import { runStorageMigrations, } from "@/services/storage/migrations"; import type { Bookmark, SceneTracks, TScene } from "@/timeline"; +import { roundMediaTime } from "@/wasm"; function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { if (!Array.isArray(raw)) return []; return raw .map((item): Bookmark | null => { - if (typeof item === "number") return { time: item }; + if (typeof item === "number") { + return { time: roundMediaTime({ time: item }) }; + } const obj = item as Record; if ( typeof obj !== "object" || @@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { return null; } return { - time: obj.time, + time: roundMediaTime({ time: obj.time }), ...(typeof obj.note === "string" && { note: obj.note }), ...(typeof obj.color === "string" && { color: obj.color }), - ...(typeof obj.duration === "number" && { duration: obj.duration }), + ...(typeof obj.duration === "number" && { + duration: roundMediaTime({ time: obj.duration }), + }), }; }) .filter((b): b is Bookmark => b !== null); @@ -198,9 +203,11 @@ class StorageService { id: serializedProject.metadata.id, name: serializedProject.metadata.name, thumbnail: serializedProject.metadata.thumbnail, - duration: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ scenes }), + duration: roundMediaTime({ + time: + serializedProject.metadata.duration ?? + getProjectDurationFromScenes({ scenes }), + }), createdAt: new Date(serializedProject.metadata.createdAt), updatedAt: new Date(serializedProject.metadata.updatedAt), }, @@ -253,11 +260,13 @@ class StorageService { id: serializedProject.metadata.id, name: serializedProject.metadata.name, thumbnail: serializedProject.metadata.thumbnail, - duration: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ - scenes: (serializedProject.scenes ?? []) as unknown as TScene[], - }), + duration: roundMediaTime({ + time: + serializedProject.metadata.duration ?? + getProjectDurationFromScenes({ + scenes: (serializedProject.scenes ?? []) as unknown as TScene[], + }), + }), createdAt: new Date(serializedProject.metadata.createdAt), updatedAt: new Date(serializedProject.metadata.updatedAt), }); diff --git a/apps/web/src/subtitles/build-subtitle-text-element.ts b/apps/web/src/subtitles/build-subtitle-text-element.ts index c4ac21ac..4e0bdb85 100644 --- a/apps/web/src/subtitles/build-subtitle-text-element.ts +++ b/apps/web/src/subtitles/build-subtitle-text-element.ts @@ -5,7 +5,7 @@ import { setCanvasLetterSpacing, } from "@/text/layout"; import { DEFAULTS } from "@/timeline/defaults"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds } from "@/wasm"; import type { CreateTextElement } from "@/timeline"; import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; @@ -310,8 +310,8 @@ export function buildSubtitleTextElement({ ...DEFAULTS.text.element, name: `Caption ${index + 1}`, content, - duration: Math.round(caption.duration * TICKS_PER_SECOND), - startTime: Math.round(caption.startTime * TICKS_PER_SECOND), + duration: mediaTimeFromSeconds({ seconds: caption.duration }), + startTime: mediaTimeFromSeconds({ seconds: caption.startTime }), fontSize: style.fontSize, fontFamily: style.fontFamily, color: style.color, diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx index 25a49893..ffabe3b8 100644 --- a/apps/web/src/text/components/assets-view.tsx +++ b/apps/web/src/text/components/assets-view.tsx @@ -3,11 +3,12 @@ import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { useEditor } from "@/editor/use-editor"; import { DEFAULTS } from "@/timeline/defaults"; import { buildTextElement } from "@/timeline/element-utils"; +import type { MediaTime } from "@/wasm"; export function TextView() { const editor = useEditor(); - const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { + const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => { const activeScene = editor.scenes.getActiveScene(); if (!activeScene) return; diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts new file mode 100644 index 00000000..94b32ced --- /dev/null +++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import type { Transform } from "@/rendering"; +import type { SceneTracks, VideoElement } from "@/timeline"; +import { applyElementUpdate } from "@/timeline/update-pipeline"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +function buildVideoElement(overrides: Partial = {}): VideoElement { + return { + id: "video-1", + type: "video", + name: "Video 1", + startTime: ZERO_MEDIA_TIME, + duration: mediaTime({ ticks: 10 }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + mediaId: "media-1", + transform: buildTransform(), + opacity: 1, + ...overrides, + }; +} + +function buildTracks(element: VideoElement): SceneTracks { + return { + overlay: [], + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [element], + }, + audio: [], + }; +} + +describe("applyElementUpdate", () => { + test("rounds retimed durations back to integer media time", () => { + const element = buildVideoElement(); + const tracks = buildTracks(element); + + const updatedElement = applyElementUpdate({ + element, + patch: { + retime: { rate: 1.5 }, + }, + context: { + tracks, + trackId: tracks.main.id, + }, + }); + + expect(updatedElement.duration).toBe(7); + expect(Number.isInteger(updatedElement.duration)).toBe(true); + }); +}); diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx index 20e22c57..0f0710cb 100644 --- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -26,6 +26,13 @@ import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp, formatNumberForDisplay } from "@/utils/math"; import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline"; +import { + type MediaTime, + mediaTimeFromSeconds, + mediaTimeToSeconds, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; @@ -39,12 +46,12 @@ function seekToBookmarkTime({ time, }: { editor: EditorCore; - time: number; + time: MediaTime; }) { const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const rate = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; + const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime; editor.playback.seek({ time: snappedTime }); } @@ -137,7 +144,7 @@ function TimelineBookmark({ const displayTime = isDragging ? dragState.currentTime : bookmark.time; const time = bookmark.time; - const bookmarkDuration = bookmark.duration ?? 0; + const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME; const durationWidth = bookmarkDuration > 0 ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) @@ -182,7 +189,7 @@ function TimelineBookmark({ left: `${bookmarkLeft}px`, width: `${bookmarkWidth}px`, }} - aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} + aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`} type="button" onMouseDown={handleMouseDown} onClick={handleClick} @@ -286,8 +293,8 @@ function BookmarkPopoverContent({ onPopoverClose, }: { bookmark: Bookmark; - time: number; - timelineDuration: number; + time: MediaTime; + timelineDuration: MediaTime; onPopoverClose: () => void; }) { const editor = useEditor(); @@ -314,9 +321,8 @@ function BookmarkPopoverContent({ note, color, duration, - }: Partial<{ note: string; color: string; duration: number }>) => { - const updates: Partial<{ note: string; color: string; duration: number }> = - {}; + }: Partial>) => { + const updates: Partial> = {}; if (note !== undefined && note !== bookmark.note) updates.note = note; if ( color !== undefined && @@ -330,6 +336,15 @@ function BookmarkPopoverContent({ if (Object.keys(updates).length === 0) return; editor.scenes.updateBookmark({ time, updates }); }; + const maxDuration = mediaTimeToSeconds({ + time: + timelineDuration > time + ? subMediaTime({ a: timelineDuration, b: time }) + : ZERO_MEDIA_TIME, + }); + const durationSeconds = mediaTimeToSeconds({ + time: bookmark.duration ?? ZERO_MEDIA_TIME, + }); return ( <> @@ -387,7 +402,7 @@ function BookmarkPopoverContent({ type="number" min={0} step={0.1} - value={bookmark.duration ?? 0} + value={durationSeconds} onChange={(event) => { const parsed = parseFloat(event.target.value); const value = Number.isNaN(parsed) @@ -395,9 +410,11 @@ function BookmarkPopoverContent({ : clamp({ value: parsed, min: 0, - max: Math.max(0, timelineDuration - time), + max: maxDuration, }); - handleUpdate({ duration: value }); + handleUpdate({ + duration: mediaTimeFromSeconds({ seconds: value }), + }); }} className="h-8 text-sm" containerClassName="w-full" diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 1071c6dd..8996330b 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -21,15 +21,16 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; import type { Bookmark } from "@/timeline"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export interface BookmarkDragState { isDragging: boolean; - bookmarkTime: number | null; - currentTime: number; + bookmarkTime: MediaTime | null; + currentTime: MediaTime; } interface PendingBookmarkDrag { - bookmarkTime: number; + bookmarkTime: MediaTime; startMouseX: number; startMouseY: number; } @@ -58,7 +59,7 @@ export function useBookmarkDrag({ const [dragState, setDragState] = useState({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef(null); @@ -69,8 +70,8 @@ export function useBookmarkDrag({ bookmarkTime, initialCurrentTime, }: { - bookmarkTime: number; - initialCurrentTime: number; + bookmarkTime: MediaTime; + initialCurrentTime: MediaTime; }) => { setDragState({ isDragging: true, @@ -85,7 +86,7 @@ export function useBookmarkDrag({ setDragState({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); }, []); @@ -94,9 +95,9 @@ export function useBookmarkDrag({ rawTime, excludeBookmarkTime, }: { - rawTime: number; - excludeBookmarkTime: number; - }): { snappedTime: number; snapPoint: SnapPoint | null } => { + rawTime: MediaTime; + excludeBookmarkTime: MediaTime; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => { const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (!shouldSnap) { return { snappedTime: rawTime, snapPoint: null }; @@ -116,7 +117,7 @@ export function useBookmarkDrag({ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), }); return { - snappedTime: result.snappedTime, + snappedTime: result.snappedTime as MediaTime, snapPoint: result.snapPoint, }; }, @@ -162,11 +163,14 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const frameSnappedTime = + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = ( roundToFrame({ - time: Math.max(0, Math.min(mouseTime, duration)), + time: clampedTime, rate: activeProject.settings.fps, - }) ?? Math.max(0, Math.min(mouseTime, duration)); + }) ?? clampedTime + ) as MediaTime; const { snappedTime: initialTime } = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: bookmarkTime, @@ -193,10 +197,12 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const clampedTime = Math.max(0, Math.min(mouseTime, duration)); - const frameSnappedTime = + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = ( roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? - clampedTime; + clampedTime + ) as MediaTime; const snapResult = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: dragState.bookmarkTime, @@ -234,10 +240,8 @@ export function useBookmarkDrag({ return; } - const clampedTime = Math.max( - 0, - Math.min(dragState.currentTime, duration), - ); + const clampedTime = + dragState.currentTime > duration ? duration : dragState.currentTime; editor.scenes.moveBookmark({ fromTime: dragState.bookmarkTime, diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx index 8e2b4bcf..2c254c84 100644 --- a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx +++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx @@ -5,6 +5,7 @@ import { type PreviewOverlaySourceResult, } from "@/preview/overlays"; import { getBookmarksActiveAtTime } from "./utils"; +import type { MediaTime } from "@/wasm"; export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { id: "bookmark-notes", @@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { function BookmarkNotesOverlay({ bookmarks, }: { - bookmarks: Array<{ time: number; note: string; color?: string }>; + bookmarks: Array<{ time: MediaTime; note: string; color?: string }>; }) { return (
@@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({ isVisible, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; isVisible: boolean; }): PreviewOverlaySourceResult { const bookmarksWithNotes = getBookmarksActiveAtTime({ diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts index d05fe3a9..52e5aa60 100644 --- a/apps/web/src/timeline/bookmarks/utils.ts +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -1,13 +1,14 @@ import type { Bookmark } from "@/timeline"; import type { FrameRate } from "opencut-wasm"; import { roundToFrame } from "opencut-wasm"; +import { addMediaTime, type MediaTime } from "@/wasm"; function bookmarkTimeEqual({ bookmarkTime, frameTime, }: { - bookmarkTime: number; - frameTime: number; + bookmarkTime: MediaTime; + frameTime: MediaTime; }): boolean { return bookmarkTime === frameTime; } @@ -17,7 +18,7 @@ export function findBookmarkIndex({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): number { return bookmarks.findIndex((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -29,7 +30,7 @@ export function isBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): boolean { return bookmarks.some((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -41,7 +42,7 @@ export function toggleBookmarkInArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); @@ -58,7 +59,7 @@ export function removeBookmarkFromArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { return bookmarks.filter( (bookmark) => @@ -72,7 +73,7 @@ export function updateBookmarkInArray({ updates, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; updates: Partial>; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime }); @@ -92,8 +93,8 @@ export function moveBookmarkInArray({ toTime, }: { bookmarks: Bookmark[]; - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); if (index === -1) { @@ -110,10 +111,10 @@ export function getFrameTime({ time, fps, }: { - time: number; + time: MediaTime; fps: FrameRate; -}): number { - return roundToFrame({ time, rate: fps }) ?? time; +}): MediaTime { + return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime; } export function getBookmarkAtTime({ @@ -121,7 +122,7 @@ export function getBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark | null { const index = findBookmarkIndex({ bookmarks, frameTime }); return index === -1 ? null : bookmarks[index]; @@ -132,13 +133,13 @@ export function getBookmarksActiveAtTime({ time, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; }): Bookmark[] { return bookmarks.filter((bookmark) => { const start = bookmark.time; const end = bookmark.duration != null && bookmark.duration > 0 - ? start + bookmark.duration + ? addMediaTime({ a: start, b: bookmark.duration }) : start; return time >= start && time <= end; }); diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts index be2495e4..f68a4b72 100644 --- a/apps/web/src/timeline/components/drop-target.ts +++ b/apps/web/src/timeline/components/drop-target.ts @@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline"; import { resolveTrackPlacement } from "@/timeline/placement"; import { TIMELINE_TRACK_GAP_PX } from "./layout"; import { getTrackHeight } from "./track-layout"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + mediaTime, + type MediaTime, + roundMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; function findElementAtPosition({ mouseX, @@ -20,9 +25,11 @@ function findElementAtPosition({ pixelsPerSecond: number; zoomLevel: number; }): { elementId: string; trackId: string } | null { - const time = Math.round( - (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + const time = mediaTime({ + ticks: Math.round( + (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, + ), + }); const track = tracks[trackIndex]; if (!track || !("elements" in track)) return null; @@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null; function fallbackNewTrackDropTarget({ xPosition, }: { - xPosition: number; + xPosition: MediaTime; }): DropTarget { return { trackIndex: 0, @@ -111,13 +118,16 @@ export function computeDropTarget({ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const mainTrackIndex = tracks.overlay.length; const xPosition = - typeof startTimeOverride === "number" + startTimeOverride !== undefined ? startTimeOverride : isExternalDrop ? playheadTime - : Math.round( - Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + : mediaTime({ + ticks: Math.round( + Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * + TICKS_PER_SECOND, + ), + }); if (orderedTracks.length === 0) { const placementResult = resolveTrackPlacement({ @@ -221,7 +231,10 @@ export function computeDropTarget({ } if (placementResult.kind === "existingTrack") { - const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; + const adjustedXPosition = + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ time: placementResult.adjustedStartTime }) + : xPosition; return { trackIndex: placementResult.trackIndex, diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index e6a9df49..5d61a0a9 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,6 +29,7 @@ import { useState, type ReactNode, } from "react"; +import type { MediaTime } from "@/wasm"; import type { ElementDragState, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; @@ -132,7 +133,7 @@ export function Timeline() { [scene], ); const mainTrackId = scene?.tracks.main.id ?? null; - const seek = (time: number) => editor.playback.seek({ time }); + const seek = (time: MediaTime) => editor.playback.seek({ time }); const timelineRef = useRef(null); const timelineHeaderRef = useRef(null); diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index 74c34607..e0eeb378 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -49,7 +49,7 @@ import { import { buildWaveformGainSamples } from "@/timeline/audio-state"; import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { addMediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm"; import { getActionDefinition, type TAction, @@ -257,10 +257,11 @@ export function TimelineElement({ isBeingDragged && dragState.isDragging ? dragState.currentMouseY - dragState.startMouseY : 0; - const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; + const dragTimeOffset = + dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME; const elementStartTime = isBeingDragged && dragState.isDragging - ? dragState.currentTime + dragTimeOffset + ? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset }) : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx index 4dd3329f..5b1e45ab 100644 --- a/apps/web/src/timeline/components/timeline-playhead.tsx +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -7,7 +7,14 @@ import { timelineTimeToSnappedPixels, } from "@/timeline"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout"; import { TIMELINE_LAYERS } from "./layers"; @@ -72,17 +79,24 @@ export function TimelinePlayhead({ event.preventDefault(); const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: 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 * ticksPerFrame), - ); + const nextTime = + direction > 0 + ? addMediaTime({ a: now, b: ticksPerFrame }) + : subMediaTime({ a: now, b: ticksPerFrame }); - editor.playback.seek({ time: nextTime }); + editor.playback.seek({ + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: duration < nextTime ? duration : nextTime, + }), + }); }; return ( diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index 2ac7f246..f3c93b8d 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,3 +1,5 @@ -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { mediaTime, TICKS_PER_SECOND } from "@/wasm"; -export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; +export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({ + ticks: 5 * TICKS_PER_SECOND, +}); diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts index 06639e01..3b47b4f4 100644 --- a/apps/web/src/timeline/defaults.ts +++ b/apps/web/src/timeline/defaults.ts @@ -1,6 +1,7 @@ import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import type { TTimelineViewState } from "@/project/types"; import type { BlendMode, Transform } from "@/rendering"; +import { ZERO_MEDIA_TIME } from "@/wasm"; import type { TextElement } from "./types"; const defaultTransform: Transform = { @@ -42,9 +43,9 @@ const defaultTextElement: Omit = { letterSpacing: defaultTextLetterSpacing, lineHeight: defaultTextLineHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, + startTime: ZERO_MEDIA_TIME, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...defaultTransform, position: { ...defaultTransform.position }, @@ -55,7 +56,7 @@ const defaultTextElement: Omit = { const defaultTimelineViewState: TTimelineViewState = { zoomLevel: 1, scrollLeft: 0, - playheadTime: 0, + playheadTime: ZERO_MEDIA_TIME, }; export const DEFAULTS = { diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts index a731d7a0..ad8d8001 100644 --- a/apps/web/src/timeline/drag-utils.ts +++ b/apps/web/src/timeline/drag-utils.ts @@ -1,5 +1,5 @@ import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; export function getMouseTimeFromClientX({ clientX, @@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({ containerRect: DOMRect; zoomLevel: number; scrollLeft: number; -}): number { +}): MediaTime { const mouseX = clientX - containerRect.left + scrollLeft; const seconds = Math.max( 0, mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ + ticks: Math.round(seconds * TICKS_PER_SECOND), + }); } diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts index 676bc823..cddbf6fd 100644 --- a/apps/web/src/timeline/element-utils.ts +++ b/apps/web/src/timeline/element-utils.ts @@ -29,6 +29,7 @@ import { buildDefaultEffectInstance } from "@/effects"; import { buildDefaultGraphicInstance } from "@/graphics"; import type { ParamValues } from "@/params"; import { capitalizeFirstLetter } from "@/utils/string"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export function canElementHaveAudio( element: TimelineElement, @@ -107,7 +108,7 @@ export function buildTextElement({ startTime, }: { raw: Partial>; - startTime: number; + startTime: MediaTime; }): CreateTimelineElement { const t = raw as Partial; @@ -117,8 +118,8 @@ export function buildTextElement({ content: t.content ?? DEFAULTS.text.element.content, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, color: t.color ?? DEFAULTS.text.element.color, @@ -141,8 +142,8 @@ export function buildEffectElement({ duration, }: { effectType: string; - startTime: number; - duration?: number; + startTime: MediaTime; + duration?: MediaTime; }): CreateEffectElement { const instance = buildDefaultEffectInstance({ effectType }); return { @@ -152,8 +153,8 @@ export function buildEffectElement({ params: instance.params, duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, }; } @@ -166,7 +167,7 @@ export function buildStickerElement({ }: { stickerId: string; name?: string; - startTime: number; + startTime: MediaTime; intrinsicWidth?: number; intrinsicHeight?: number; }): CreateStickerElement { @@ -180,8 +181,8 @@ export function buildStickerElement({ intrinsicHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...DEFAULTS.element.transform, position: { ...DEFAULTS.element.transform.position }, @@ -199,7 +200,7 @@ export function buildGraphicElement({ }: { definitionId: string; name?: string; - startTime: number; + startTime: MediaTime; params?: Partial; }): CreateGraphicElement { const instance = buildDefaultGraphicInstance({ definitionId }); @@ -210,8 +211,8 @@ export function buildGraphicElement({ params: { ...instance.params, ...(params ?? {}) } as ParamValues, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...DEFAULTS.element.transform, position: { ...DEFAULTS.element.transform.position }, @@ -229,8 +230,8 @@ function buildVideoElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateVideoElement { return { type: "video", @@ -238,8 +239,8 @@ function buildVideoElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, muted: false, isSourceAudioEnabled: true, @@ -262,8 +263,8 @@ function buildImageElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateImageElement { return { type: "image", @@ -271,8 +272,8 @@ function buildImageElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, hidden: false, transform: { ...DEFAULTS.element.transform, @@ -292,8 +293,8 @@ function buildUploadAudioElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateUploadAudioElement { const element: CreateUploadAudioElement = { @@ -303,8 +304,8 @@ function buildUploadAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, volume: DEFAULTS.element.volume, muted: false, @@ -326,8 +327,8 @@ export function buildElementFromMedia({ mediaId: string; mediaType: MediaType; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateTimelineElement { switch (mediaType) { @@ -355,8 +356,8 @@ export function buildLibraryAudioElement({ }: { sourceUrl: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateLibraryAudioElement { const element: CreateLibraryAudioElement = { @@ -366,8 +367,8 @@ export function buildLibraryAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, volume: DEFAULTS.element.volume, muted: false, diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts index 00f39d55..3e19e3e3 100644 --- a/apps/web/src/timeline/group-move/build-group.ts +++ b/apps/web/src/timeline/group-move/build-group.ts @@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import type { GroupMember, MoveGroup } from "./types"; import { getTrackPlacementById } from "./track-placement"; +import { subMediaTime } from "@/wasm"; export function buildMoveGroup({ anchorRef, @@ -59,7 +60,10 @@ export function buildMoveGroup({ elementId: element.id, elementType: element.type, duration: element.duration, - timeOffset: element.startTime - anchorElement.startTime, + timeOffset: subMediaTime({ + a: element.startTime, + b: anchorElement.startTime, + }), trackSection: placement.section, sectionIndex: placement.sectionIndex, displayIndex: placement.displayIndex, diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts index 60940315..8307aad0 100644 --- a/apps/web/src/timeline/group-move/resolve-move.ts +++ b/apps/web/src/timeline/group-move/resolve-move.ts @@ -12,6 +12,13 @@ import { getTrackPlacementByDisplayIndex, getTrackPlacementById, } from "./track-placement"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; type GroupMoveTarget = | { @@ -32,7 +39,7 @@ export function resolveGroupMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; target: GroupMoveTarget; }): GroupMoveResult | null { if (target.kind === "newTracks") { @@ -61,7 +68,7 @@ function resolveExistingTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorTargetTrackId: string; }): GroupMoveResult | null { const anchorTargetPlacement = getTrackPlacementById({ @@ -93,7 +100,10 @@ function resolveExistingTrackMove({ targetTrackId: targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); if (!canApplyMovesToExistingTracks({ tracks, moves })) { @@ -119,7 +129,7 @@ function resolveNewTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorInsertIndex: number; newTrackIds: string[]; }): GroupMoveResult | null { @@ -173,7 +183,10 @@ function resolveNewTrackMove({ sourceTrackId: member.trackId, targetTrackId: newTrackIds[memberIndex], elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); return { @@ -335,17 +348,26 @@ function clampAnchorStartTime({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; targetTrackIdsByElementId: Map; -}): number { - const minimumAnchorStartTime = Math.max( - 0, - ...group.members.map((member) => -member.timeOffset), - ); - let clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - anchorStartTime, +}): MediaTime { + const minimumAnchorStartTime = group.members.reduce( + (minimumStartTime, member) => + member.timeOffset < ZERO_MEDIA_TIME + ? maxMediaTime({ + a: minimumStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: member.timeOffset, + }), + }) + : minimumStartTime, + ZERO_MEDIA_TIME, ); + let clampedAnchorStartTime = + anchorStartTime < minimumAnchorStartTime + ? minimumAnchorStartTime + : anchorStartTime; const memberOnMainTrack = group.members.find( (member) => @@ -358,11 +380,13 @@ function clampAnchorStartTime({ const movingElementIds = new Set( group.members.map((member) => member.elementId), ); - const requestedMainStartTime = - clampedAnchorStartTime + memberOnMainTrack.timeOffset; + const requestedMainStartTime = addMediaTime({ + a: clampedAnchorStartTime, + b: memberOnMainTrack.timeOffset, + }); const earliestStationaryMainStartTime = tracks.main.elements .filter((element) => !movingElementIds.has(element.id)) - .reduce((earliestStartTime, element) => { + .reduce((earliestStartTime, element) => { if (earliestStartTime == null || element.startTime < earliestStartTime) { return element.startTime; } @@ -373,10 +397,13 @@ function clampAnchorStartTime({ earliestStationaryMainStartTime == null || requestedMainStartTime <= earliestStationaryMainStartTime ) { - clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - -memberOnMainTrack.timeOffset, - ); + clampedAnchorStartTime = maxMediaTime({ + a: minimumAnchorStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: memberOnMainTrack.timeOffset, + }), + }); } return clampedAnchorStartTime; @@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({ const sourceElement = sourceElements.get(move.elementId); return { startTime: move.newStartTime, - duration: sourceElement?.duration ?? 0, + duration: sourceElement?.duration ?? ZERO_MEDIA_TIME, }; }); if (hasOverlappingTimeSpans({ timeSpans })) { diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index 32c0a2f1..aa06a35b 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -9,6 +9,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; import type { MoveGroup } from "./types"; +import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm"; export function snapGroupEdges({ group, @@ -18,12 +19,12 @@ export function snapGroupEdges({ zoomLevel, }: { group: MoveGroup; - anchorStartTime: number; + anchorStartTime: MediaTime; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; zoomLevel: number; }): { - snappedAnchorStartTime: number; + snappedAnchorStartTime: MediaTime; snapPoint: SnapPoint | null; } { const excludeElementIds = new Set( @@ -47,7 +48,10 @@ export function snapGroupEdges({ let snapPoint: SnapPoint | null = null; for (const member of group.members) { - const memberStartTime = anchorStartTime + member.timeOffset; + const memberStartTime = addMediaTime({ + a: anchorStartTime, + b: member.timeOffset, + }); const memberStartSnap = resolveTimelineSnap({ targetTime: memberStartTime, snapPoints, @@ -58,12 +62,18 @@ export function snapGroupEdges({ memberStartSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberStartSnap.snapDistance; - snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: memberStartSnap.snappedTime as MediaTime, + b: member.timeOffset, + }); snapPoint = memberStartSnap.snapPoint; } const memberEndSnap = resolveTimelineSnap({ - targetTime: memberStartTime + member.duration, + targetTime: addMediaTime({ + a: memberStartTime, + b: member.duration, + }), snapPoints, maxSnapDistance, }); @@ -72,8 +82,13 @@ export function snapGroupEdges({ memberEndSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberEndSnap.snapDistance; - snappedAnchorStartTime = - memberEndSnap.snappedTime - member.duration - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: subMediaTime({ + a: memberEndSnap.snappedTime as MediaTime, + b: member.duration, + }), + b: member.timeOffset, + }); snapPoint = memberEndSnap.snapPoint; } } diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts index daf9c6e9..f97b698c 100644 --- a/apps/web/src/timeline/group-move/types.ts +++ b/apps/web/src/timeline/group-move/types.ts @@ -1,11 +1,12 @@ import type { ElementRef, ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export type GroupTrackSection = "overlay" | "main" | "audio"; export interface GroupMember extends ElementRef { elementType: ElementType; - duration: number; - timeOffset: number; + duration: MediaTime; + timeOffset: MediaTime; trackSection: GroupTrackSection; sectionIndex: number; displayIndex: number; @@ -26,7 +27,7 @@ export interface PlannedElementMove { sourceTrackId: string; targetTrackId: string; elementId: string; - newStartTime: number; + newStartTime: MediaTime; } export interface GroupMoveResult { diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index dd542967..bbe829cc 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -3,7 +3,7 @@ import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/retime"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { TICKS_PER_SECOND, roundMediaTime } from "@/wasm"; import type { ComputeGroupResizeArgs, GroupResizeMember, @@ -188,15 +188,17 @@ function getSourceDeltaForClipDelta({ return clipDelta; } - return clipDelta >= 0 - ? getSourceSpanAtClipTime({ - clipTime: clipDelta, - retime: member.retime, - }) - : -getSourceSpanAtClipTime({ - clipTime: Math.abs(clipDelta), - retime: member.retime, - }); + const sourceDelta = + clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: member.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: member.retime, + }); + return roundMediaTime({ time: sourceDelta }); } function getVisibleSourceSpanForDuration({ @@ -227,9 +229,11 @@ function getDurationForVisibleSourceSpan({ return sourceSpan; } - return getTimelineDurationForSourceSpan({ - sourceSpan, - retime: member.retime, + return roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan, + retime: member.retime, + }), }); } 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; headerRef?: RefObject; 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 = {}; + const dragTimeOffsets: Record = {}; 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; - 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(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(null); + const scrubTimeRef = useRef(null); const isDraggingRulerRef = useRef(false); const hasDraggedRulerRef = useRef(false); const lastMouseXRef = useRef(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, })), }); }; @@ -155,7 +155,7 @@ export function useTimelineResize({ updates: result.updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - patch, + patch: patch as Partial, })), }); } 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; tracksScrollRef: RefObject; 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; minZoom?: number; initialZoom?: number; initialScrollLeft?: number; - initialPlayheadTime?: number; + initialPlayheadTime?: MediaTime; tracksScrollRef: RefObject; rulerScrollRef: RefObject; } @@ -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) diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts index 5ee079b1..3775e006 100644 --- a/apps/web/src/timeline/index.ts +++ b/apps/web/src/timeline/index.ts @@ -1,29 +1,32 @@ -import type { SceneTracks } from "./types"; - -export * from "./types"; -export * from "./drag"; -export * from "./track-capabilities"; -export * from "./track-element-update"; -export * from "./element-utils"; -export * from "./audio-separation"; -export * from "./zoom-utils"; -export * from "./ruler-utils"; -export * from "./pixel-utils"; - -export function calculateTotalDuration({ - tracks, -}: { - tracks: SceneTracks; -}): number { - const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - if (orderedTracks.length === 0) return 0; - - const trackEndTimes = orderedTracks.map((track) => - track.elements.reduce((maxEnd, element) => { - const elementEnd = element.startTime + element.duration; - return Math.max(maxEnd, elementEnd); - }, 0), - ); - - return Math.max(...trackEndTimes, 0); -} +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import type { SceneTracks } from "./types"; + +export * from "./types"; +export * from "./drag"; +export * from "./track-capabilities"; +export * from "./track-element-update"; +export * from "./element-utils"; +export * from "./audio-separation"; +export * from "./zoom-utils"; +export * from "./ruler-utils"; +export * from "./pixel-utils"; + +export function calculateTotalDuration({ + tracks, +}: { + tracks: SceneTracks; +}): MediaTime { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + if (orderedTracks.length === 0) return ZERO_MEDIA_TIME; + + let maxEnd: MediaTime = ZERO_MEDIA_TIME; + for (const track of orderedTracks) { + for (const element of track.elements) { + // `startTime + duration` is integer-by-construction (both `MediaTime`), + // so the cast re-establishes the brand without runtime work. + const elementEnd = (element.startTime + element.duration) as MediaTime; + if (elementEnd > maxEnd) maxEnd = elementEnd; + } + } + return maxEnd; +} diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts index ebbfe578..44f0a1fe 100644 --- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts @@ -14,6 +14,7 @@ import type { } from "@/timeline"; import type { Transform } from "@/rendering"; import { resolveTrackPlacement } from "@/timeline/placement"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; function buildTransform(): Transform { return { @@ -67,10 +68,10 @@ function buildElement({ id, type: "audio", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, volume: 1, sourceType: "upload", mediaId: `media-${id}`, @@ -80,10 +81,10 @@ function buildElement({ id, type: "graphic", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, definitionId: `graphic-${id}`, params: {}, transform: buildTransform(), @@ -94,10 +95,10 @@ function buildElement({ id, type: "text", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, content: id, fontSize: 32, fontFamily: "sans-serif", @@ -118,10 +119,10 @@ function buildElement({ id, type: "video", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, mediaId: `media-${id}`, transform: buildTransform(), opacity: 1, @@ -224,7 +225,11 @@ function buildTimeSpan({ duration: number; excludeElementId?: string; }) { - return { startTime, duration, excludeElementId }; + return { + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + excludeElementId, + }; } function buildSceneTracks({ diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts index ab2ab7bd..36632226 100644 --- a/apps/web/src/timeline/placement/main-track.ts +++ b/apps/web/src/timeline/placement/main-track.ts @@ -1,4 +1,5 @@ import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export const MAIN_TRACK_NAME = "Main Track"; @@ -31,9 +32,9 @@ export function enforceMainTrackStart({ }: { tracks: SceneTracks; targetTrackId: string; - requestedStartTime: number; + requestedStartTime: MediaTime; excludeElementId?: string; -}): number { +}): MediaTime { if (tracks.main.id !== targetTrackId) { return requestedStartTime; } @@ -43,11 +44,11 @@ export function enforceMainTrackStart({ excludeElementId, }); if (!earliestElement) { - return 0; + return ZERO_MEDIA_TIME; } if (requestedStartTime <= earliestElement.startTime) { - return 0; + return ZERO_MEDIA_TIME; } return requestedStartTime; diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts index 26275392..680cb0bb 100644 --- a/apps/web/src/timeline/placement/resolve.ts +++ b/apps/web/src/timeline/placement/resolve.ts @@ -13,6 +13,7 @@ import type { PlacementSubject, PlacementTimeSpan, } from "./types"; +import { ZERO_MEDIA_TIME } from "@/wasm"; type ResolveTrackPlacementParams = PlacementSubject & { tracks: SceneTracks; @@ -32,7 +33,7 @@ function buildExistingTrackResult({ timeSpans: PlacementTimeSpan[]; }): PlacementResult { const firstSpan = timeSpans[0]; - const requestedStartTime = firstSpan?.startTime ?? 0; + const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME; const adjustedStartTime = enforceMainTrackStart({ tracks, targetTrackId: track.id, diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts index 6847ab36..748f34d7 100644 --- a/apps/web/src/timeline/placement/types.ts +++ b/apps/web/src/timeline/placement/types.ts @@ -1,8 +1,9 @@ import type { ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface PlacementTimeSpan { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; excludeElementId?: string; } @@ -29,7 +30,7 @@ export type PlacementResult = trackId: string; trackIndex: number; trackType: TrackType; - adjustedStartTime?: number; + adjustedStartTime?: MediaTime; } | { kind: "newTrack"; diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts index d48c943c..8aa1b6b5 100644 --- a/apps/web/src/timeline/scenes.ts +++ b/apps/web/src/timeline/scenes.ts @@ -2,6 +2,7 @@ import type { TScene } from "@/timeline"; import { generateUUID } from "@/utils/id"; import { calculateTotalDuration } from "@/timeline"; import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { return scenes.find((scene) => scene.isMain) || null; @@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({ scenes, }: { scenes: TScene[]; -}): number { +}): MediaTime { const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null; if (!mainScene?.tracks) { - return 0; + return ZERO_MEDIA_TIME; } return calculateTotalDuration({ tracks: mainScene.tracks }); diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index cd543cdf..2a30a065 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -3,6 +3,7 @@ import type { Effect } from "@/effects/types"; import type { Mask } from "@/masks/types"; import type { ParamValues } from "@/params"; import type { BlendMode, Transform } from "@/rendering"; +import type { MediaTime } from "@/wasm"; export type ElementRef = { trackId: string; @@ -10,10 +11,10 @@ export type ElementRef = { }; export interface Bookmark { - time: number; + time: MediaTime; note?: string; color?: string; - duration?: number; + duration?: MediaTime; } export interface TScene { @@ -107,11 +108,11 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement; interface BaseTimelineElement { id: string; name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; + duration: MediaTime; + startTime: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; animations?: ElementAnimations; } @@ -273,13 +274,13 @@ export interface ElementDragState { isDragging: boolean; elementId: string | null; dragElementIds: string[]; - dragTimeOffsets: Record; + dragTimeOffsets: Record; trackId: string | null; startMouseX: number; startMouseY: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; + startElementTime: MediaTime; + clickOffsetTime: MediaTime; + currentTime: MediaTime; currentMouseY: number; } @@ -287,7 +288,7 @@ export interface DropTarget { trackIndex: number; isNewTrack: boolean; insertPosition: "above" | "below" | null; - xPosition: number; + xPosition: MediaTime; targetElement: { elementId: string; trackId: string } | null; } @@ -296,13 +297,13 @@ export interface ComputeDropTargetParams { mouseX: number; mouseY: number; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; isExternalDrop: boolean; - elementDuration: number; + elementDuration: MediaTime; pixelsPerSecond: number; zoomLevel: number; verticalDragDirection?: "up" | "down" | null; - startTimeOverride?: number; + startTimeOverride?: MediaTime; excludeElementId?: string; targetElementTypes?: string[]; } diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts index beacf897..7ab129e9 100644 --- a/apps/web/src/timeline/update-pipeline.ts +++ b/apps/web/src/timeline/update-pipeline.ts @@ -6,6 +6,7 @@ import { } from "@/retime"; import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline"; import { isRetimableElement } from "@/timeline"; +import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm"; type ElementUpdateField = keyof TimelineElement | string; @@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [ 0, sourceDuration - element.trimStart - element.trimEnd, ); - const nextDuration = getTimelineDurationForSourceSpan({ - sourceSpan: visibleSourceSpan, - retime: nextRetime, + const nextDuration = roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }), }); return { @@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [ { triggers: ["startTime"], apply: ({ element, context }) => { - const requestedStartTime = Math.max(0, element.startTime); + const requestedStartTime = + element.startTime < ZERO_MEDIA_TIME + ? ZERO_MEDIA_TIME + : element.startTime; if (context.trackId !== context.tracks.main.id) { return { element: { @@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [ ...element, startTime: !earliestElement || requestedStartTime <= earliestElement.startTime - ? 0 + ? ZERO_MEDIA_TIME : requestedStartTime, }, }; diff --git a/apps/web/src/wasm/index.ts b/apps/web/src/wasm/index.ts index 60031a0e..c979b620 100644 --- a/apps/web/src/wasm/index.ts +++ b/apps/web/src/wasm/index.ts @@ -1 +1 @@ -export * from "./ticks"; +export * from "./media-time"; diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts new file mode 100644 index 00000000..7209e81c --- /dev/null +++ b/apps/web/src/wasm/media-time.ts @@ -0,0 +1,129 @@ +import { + TICKS_PER_SECOND as _TICKS_PER_SECOND, + mediaTimeFromSeconds as _mediaTimeFromSeconds, + mediaTimeToSeconds as _mediaTimeToSeconds, +} from "opencut-wasm"; + +/** + * Integer-tick time. Mirrors `MediaTime(i64)` in `rust/crates/time/src/media_time.rs`. + * + * `opencut-wasm` exposes `MediaTime` as a bare `number` alias because tsify + * collapses tuple structs. The brand here is the TS-side discipline that + * recovers the invariant: a `MediaTime` is an integer count of ticks, and the + * only legal way to construct one from a fractional `number` is `roundMediaTime` + * (or `mediaTimeFromSeconds`, which rounds inside the wasm boundary). + * + * Reading is free — `MediaTime` is assignable to `number`. Writing is gated — + * a bare `number` is not assignable to `MediaTime`. + */ +export type MediaTime = number & { readonly __mediaTime: unique symbol }; + +export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); + +export const ZERO_MEDIA_TIME = 0 as MediaTime; + +/** + * Construct a `MediaTime` from a known-integer tick count. Asserts in dev that + * the value is actually an integer; cast in release. Use `roundMediaTime` when + * the input may be fractional. + */ +export function mediaTime({ ticks }: { ticks: number }): MediaTime { + if (process.env.NODE_ENV !== "production" && !Number.isInteger(ticks)) { + throw new Error( + `mediaTime() requires an integer tick count, got ${ticks}. Use roundMediaTime() for fractional values.`, + ); + } + return ticks as MediaTime; +} + +/** + * Project a fractional value onto the integer-tick lattice. + * + * Rounds half away from zero (`-1.5 → -2`, `1.5 → 2`) and normalises `-0` to + * `0`. The away-from-zero rule matches Rust's `.round()` and avoids the + * `Math.round(-0.5) === -0` quirk that propagates `-0` into stored data. + */ +export function roundMediaTime({ time }: { time: number }): MediaTime { + const roundedMagnitude = Math.round(Math.abs(time)); + if (roundedMagnitude === 0) { + return 0 as MediaTime; + } + return (time < 0 ? -roundedMagnitude : roundedMagnitude) as MediaTime; +} + +export function mediaTimeFromSeconds({ + seconds, +}: { + seconds: number; +}): MediaTime { + const result = _mediaTimeFromSeconds({ seconds }); + if (result === undefined) { + throw new Error( + `mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`, + ); + } + return result as MediaTime; +} + +export function mediaTimeToSeconds({ time }: { time: MediaTime }): number { + return _mediaTimeToSeconds({ time }); +} + +/** + * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too; + * the cast is a no-op at runtime, only re-establishes the brand for the type + * system. + */ +export function addMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return (a + b) as MediaTime; +} + +export function subMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return (a - b) as MediaTime; +} + +export function maxMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return (a > b ? a : b) as MediaTime; +} + +export function minMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return (a < b ? a : b) as MediaTime; +} + +export function clampMediaTime({ + time, + min, + max, +}: { + time: MediaTime; + min: MediaTime; + max: MediaTime; +}): MediaTime { + if (time < min) return min; + if (time > max) return max; + return time; +} diff --git a/apps/web/src/wasm/ticks.ts b/apps/web/src/wasm/ticks.ts deleted file mode 100644 index ababe6b3..00000000 --- a/apps/web/src/wasm/ticks.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm"; - -export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); From 685d9027409e7f19ee2dbd5fd91a0837c00eb60b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 02:59:30 +0200 Subject: [PATCH 05/29] fix: follow-up to prev commit --- apps/web/src/sounds/sounds-store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/sounds/sounds-store.ts b/apps/web/src/sounds/sounds-store.ts index b9b3f070..13ced962 100644 --- a/apps/web/src/sounds/sounds-store.ts +++ b/apps/web/src/sounds/sounds-store.ts @@ -4,6 +4,7 @@ import { storageService } from "@/services/storage/service"; import { toast } from "sonner"; import { EditorCore } from "@/core"; import { buildLibraryAudioElement } from "@/timeline/element-utils"; +import { mediaTimeFromSeconds } from "@/wasm"; interface SoundsStore { topSoundEffects: SoundEffect[]; @@ -227,7 +228,7 @@ export const useSoundsStore = create((set, get) => ({ const element = buildLibraryAudioElement({ sourceUrl: audioUrl, name: sound.name, - duration: sound.duration, + duration: mediaTimeFromSeconds({ seconds: sound.duration }), startTime: currentTime, buffer, }); From e6c6193638e4247ff1e6f2df40de2a057112c773 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 03:06:13 +0200 Subject: [PATCH 06/29] fix: linter --- .../timeline/components/timeline-element.tsx | 33 +++++++++++-------- .../timeline/hooks/use-timeline-playhead.ts | 6 ++-- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index e0eeb378..d85288b1 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -49,7 +49,12 @@ import { import { buildWaveformGainSamples } from "@/timeline/audio-state"; import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; -import { addMediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm"; +import { + addMediaTime, + type MediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { getActionDefinition, type TAction, @@ -96,7 +101,7 @@ const PixelsPerSecondContext = createContext(null); const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { - time: number; + time: MediaTime; offsetPx: number; keyframes: SelectedKeyframeRef[]; } @@ -112,11 +117,11 @@ export function buildKeyframeIndicator({ keyframe: ElementKeyframe; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; }): { - time: number; + time: MediaTime; offsetPx: number; keyframeRef: SelectedKeyframeRef; } { @@ -149,7 +154,7 @@ export function getKeyframeIndicators({ keyframes: ElementKeyframe[]; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; elementWidth: number; @@ -158,7 +163,7 @@ export function getKeyframeIndicators({ return []; } - const keyframesByTime = new Map(); + const keyframesByTime = new Map(); for (const keyframe of keyframes) { const indicator = buildKeyframeIndicator({ keyframe, @@ -662,7 +667,7 @@ function KeyframeIndicators({ }: { indicators: KeyframeIndicator[]; dragState: KeyframeDragState; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; onKeyframeMouseDown: (params: { event: React.MouseEvent; @@ -672,13 +677,13 @@ function KeyframeIndicators({ event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }) => void; getVisualOffsetPx: (params: { - indicatorTime: number; + indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; }) => number; }) { @@ -756,7 +761,7 @@ function ExpandedKeyframeLanes({ keyframes: ElementKeyframe[]; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; keyframeDragState: KeyframeDragState; @@ -777,13 +782,13 @@ function ExpandedKeyframeLanes({ event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }) => void; getVisualOffsetPx: (params: { - indicatorTime: number; + indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; }) => number; }) { diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 6fbf2147..0ec39f69 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -102,7 +102,7 @@ export function useTimelinePlayhead({ ) as MediaTime; const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - const time = (() => { + const time: MediaTime = (() => { if (!shouldSnap) return frameTime; const tracks = editor.scenes.getActiveScene().tracks; const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; @@ -118,7 +118,9 @@ export function useTimelinePlayhead({ snapPoints, maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), }); - return snapResult.snapPoint ? snapResult.snappedTime : frameTime; + return snapResult.snapPoint + ? (snapResult.snappedTime as MediaTime) + : frameTime; })(); scrubTimeRef.current = time; From 0b7597b31fa956c7f3d4838923b545ba5e341422 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 04:04:30 +0200 Subject: [PATCH 07/29] refactor: split animation helpers by domain --- .../__tests__/animated-params.test.ts | 241 +++++++ apps/web/src/animation/animated-params.ts | 83 +++ .../web/src/animation/effect-param-channel.ts | 16 +- .../src/animation/graphic-param-channel.ts | 32 +- apps/web/src/animation/index.ts | 30 +- apps/web/src/animation/interpolation.ts | 2 +- apps/web/src/animation/keyframe-query.ts | 2 +- apps/web/src/animation/keyframes.ts | 48 +- apps/web/src/animation/path.ts | 22 + apps/web/src/animation/resolve.ts | 105 --- apps/web/src/animation/types.ts | 10 +- apps/web/src/animation/values.ts | 61 ++ .../timeline/clipboard/paste-keyframes.ts | 2 +- .../element/keyframes/remove-keyframe.ts | 2 +- .../element/keyframes/retime-keyframe.ts | 3 +- .../keyframes/update-scalar-keyframe-curve.ts | 2 +- .../keyframes/upsert-effect-param-keyframe.ts | 2 +- .../element/keyframes/upsert-keyframe.ts | 3 +- .../hooks/use-keyframed-color-property.ts | 190 +++--- .../hooks/use-keyframed-number-property.ts | 348 +++++----- .../hooks/use-keyframed-param-property.ts | 14 +- .../web/src/core/managers/timeline-manager.ts | 2 +- .../src/graphics/components/graphic-tab.tsx | 479 +++++++------- apps/web/src/graphics/index.ts | 24 + .../preview/components/text-edit-overlay.tsx | 2 +- .../transform-handle-controller.ts | 2 +- apps/web/src/preview/element-bounds.ts | 616 +++++++++--------- apps/web/src/rendering/NOTES.md | 27 + apps/web/src/rendering/animation-values.ts | 49 ++ .../src/rendering/components/blending-tab.tsx | 2 +- .../rendering/components/transform-tab.tsx | 2 +- apps/web/src/services/renderer/resolve.ts | 20 +- apps/web/src/text/components/text-tab.tsx | 5 +- apps/web/src/text/measure-element.ts | 2 +- .../animation-properties.ts} | 65 +- .../animation-snap-points.ts} | 0 .../animation-targets.ts} | 123 +--- apps/web/src/timeline/audio-state.ts | 2 +- .../bookmarks/hooks/use-bookmark-drag.ts | 2 +- .../web/src/timeline/components/audio-tab.tsx | 302 ++++----- .../controllers/playhead-controller.ts | 2 +- .../timeline/controllers/resize-controller.ts | 2 +- apps/web/src/timeline/group-move/snap.ts | 2 +- 43 files changed, 1628 insertions(+), 1322 deletions(-) create mode 100644 apps/web/src/animation/__tests__/animated-params.test.ts create mode 100644 apps/web/src/animation/animated-params.ts create mode 100644 apps/web/src/animation/path.ts create mode 100644 apps/web/src/animation/values.ts create mode 100644 apps/web/src/rendering/NOTES.md create mode 100644 apps/web/src/rendering/animation-values.ts rename apps/web/src/{animation/property-registry.ts => timeline/animation-properties.ts} (90%) rename apps/web/src/{animation/timeline-snap-points.ts => timeline/animation-snap-points.ts} (100%) rename apps/web/src/{animation/target-resolver.ts => timeline/animation-targets.ts} (64%) diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts new file mode 100644 index 00000000..7b9d312d --- /dev/null +++ b/apps/web/src/animation/__tests__/animated-params.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamNumericRange, + getAnimationParamValueKind, +} from "@/animation/animated-params"; + +describe("animated params", () => { + test("snaps and clamps number params", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0, + min: 0, + max: 1, + step: 0.25, + }, + value: 0.62, + }), + ).toBe(0.5); + + expect( + coerceAnimationParamValue({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0, + min: 0, + max: 1, + step: 0.25, + }, + value: 1.2, + }), + ).toBe(1); + }); + + test("rejects NaN and non-number values for number params", () => { + const param = { + key: "intensity", + label: "Intensity", + type: "number" as const, + default: 0, + min: 0, + max: 1, + step: 0.25, + }; + expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: true })).toBeNull(); + }); + + test("passthrough with step <= 0 guard", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "x", + label: "X", + type: "number", + default: 0, + min: 0, + step: 0, + }, + value: 0.123, + }), + ).toBe(0.123); + }); + + test("accepts valid select values", () => { + const param = { + key: "blend", + label: "Blend", + type: "select" as const, + default: "normal", + options: [ + { value: "normal", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + ], + }; + expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal"); + expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply"); + }); + + test("rejects select values outside the allowed options", () => { + expect( + coerceAnimationParamValue({ + param: { + key: "blend", + label: "Blend", + type: "select", + default: "normal", + options: [ + { value: "normal", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + ], + }, + value: "screen", + }), + ).toBeNull(); + }); + + test("rejects non-string select values", () => { + const param = { + key: "blend", + label: "Blend", + type: "select" as const, + default: "normal", + options: [{ value: "normal", label: "Normal" }], + }; + expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull(); + }); + + test("boolean params accept booleans and reject other types", () => { + const param = { + key: "visible", + label: "Visible", + type: "boolean" as const, + default: true, + }; + expect(coerceAnimationParamValue({ param, value: true })).toBe(true); + expect(coerceAnimationParamValue({ param, value: false })).toBe(false); + expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull(); + }); + + test("color params accept strings and reject other types", () => { + const param = { + key: "fill", + label: "Fill", + type: "color" as const, + default: "#ffffff", + }; + expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); + expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull(); + expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); + }); + + test("getAnimationParamValueKind maps param type to binding kind", () => { + expect( + getAnimationParamValueKind({ + param: { + key: "n", + label: "N", + type: "number", + default: 0, + min: 0, + step: 1, + }, + }), + ).toBe("number"); + expect( + getAnimationParamValueKind({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBe("color"); + expect( + getAnimationParamValueKind({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBe("discrete"); + expect( + getAnimationParamValueKind({ + param: { + key: "s", + label: "S", + type: "select", + default: "a", + options: [{ value: "a", label: "A" }], + }, + }), + ).toBe("discrete"); + }); + + test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => { + expect( + getAnimationParamDefaultInterpolation({ + param: { + key: "n", + label: "N", + type: "number", + default: 0, + min: 0, + step: 1, + }, + }), + ).toBe("linear"); + expect( + getAnimationParamDefaultInterpolation({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBe("linear"); + expect( + getAnimationParamDefaultInterpolation({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBe("hold"); + expect( + getAnimationParamDefaultInterpolation({ + param: { + key: "s", + label: "S", + type: "select", + default: "a", + options: [{ value: "a", label: "A" }], + }, + }), + ).toBe("hold"); + }); + + test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => { + expect( + getAnimationParamNumericRange({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0.5, + min: 0, + max: 1, + step: 0.1, + }, + }), + ).toEqual({ min: 0, max: 1, step: 0.1 }); + expect( + getAnimationParamNumericRange({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBeUndefined(); + expect( + getAnimationParamNumericRange({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/animation/animated-params.ts b/apps/web/src/animation/animated-params.ts new file mode 100644 index 00000000..bc27b15d --- /dev/null +++ b/apps/web/src/animation/animated-params.ts @@ -0,0 +1,83 @@ +import { snapToStep } from "@/utils/math"; +import type { ParamDefinition } from "@/params"; +import type { DynamicAnimationPathValue, NumericSpec } from "./types"; + +export function getAnimationParamValueKind({ + param, +}: { + param: ParamDefinition; +}): "number" | "color" | "discrete" { + if (param.type === "number") { + return "number"; + } + + if (param.type === "color") { + return "color"; + } + + return "discrete"; +} + +export function getAnimationParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): "linear" | "hold" { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +export function getAnimationParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): NumericSpec | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + min: param.min, + max: param.max, + step: param.step, + }; +} + +/** + * `coerceAnimationParamValue` accepts `unknown` rather than a narrow + * `DynamicAnimationPathValue` because it doubles as a runtime gate for + * untrusted inputs (persisted state, paste payloads, programmatic updates). + * The caller does not need to pre-validate; null means "this value cannot live + * on this param". + */ +export function coerceAnimationParamValue({ + param, + value, +}: { + param: ParamDefinition; + value: unknown; +}): DynamicAnimationPathValue | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const minValue = param.min; + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); + } + + if (param.type === "color") { + return typeof value === "string" ? value : null; + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts index c635bb82..e1c442f7 100644 --- a/apps/web/src/animation/effect-param-channel.ts +++ b/apps/web/src/animation/effect-param-channel.ts @@ -1,9 +1,8 @@ -import type { ParamValues } from "@/params"; -import type { Effect } from "@/effects/types"; import type { ElementAnimations, EffectParamPath, } from "@/animation/types"; +import type { ParamValues } from "@/params"; import { removeElementKeyframe } from "./keyframes"; import { resolveAnimationPathValueAtTime } from "./resolve"; @@ -56,23 +55,26 @@ export function parseEffectParamPath({ } export function resolveEffectParamsAtTime({ - effect, + effectId, + params, animations, localTime, }: { - effect: Effect; + effectId: string; + params: ParamValues; animations: ElementAnimations | undefined; localTime: number; }): ParamValues { + const safeLocalTime = Math.max(0, localTime); const resolved: ParamValues = {}; - for (const [paramKey, staticValue] of Object.entries(effect.params)) { - const path = buildEffectParamPath({ effectId: effect.id, paramKey }); + for (const [paramKey, staticValue] of Object.entries(params)) { + const path = buildEffectParamPath({ effectId, paramKey }); resolved[paramKey] = animations?.bindings[path] ? resolveAnimationPathValueAtTime({ animations, propertyPath: path, - localTime, + localTime: safeLocalTime, fallbackValue: staticValue, }) : staticValue; diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts index ebf0f940..d0bfdded 100644 --- a/apps/web/src/animation/graphic-param-channel.ts +++ b/apps/web/src/animation/graphic-param-channel.ts @@ -2,11 +2,7 @@ import type { ElementAnimations, GraphicParamPath, } from "@/animation/types"; -import type { ParamValues } from "@/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/graphics"; +import type { ParamDefinition, ParamValues } from "@/params"; import { resolveAnimationPathValueAtTime } from "./resolve"; export const GRAPHIC_PARAM_PATH_PREFIX = "params."; @@ -39,33 +35,29 @@ export function parseGraphicParamPath({ } export function resolveGraphicParamsAtTime({ - element, + params, + definitions, + animations, localTime, }: { - element: { - definitionId: string; - params: ParamValues; - animations?: ElementAnimations; - }; + params: ParamValues; + definitions: ParamDefinition[]; + animations?: ElementAnimations; localTime: number; }): ParamValues { - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const baseParams = resolveGraphicParams(definition, element.params); - const resolved: ParamValues = { ...baseParams }; + const resolved: ParamValues = { ...params }; - for (const param of definition.params) { + for (const param of definitions) { const path = buildGraphicParamPath({ paramKey: param.key }); - if (!element.animations?.bindings[path]) { + if (!animations?.bindings[path]) { continue; } resolved[param.key] = resolveAnimationPathValueAtTime({ - animations: element.animations, + animations, propertyPath: path, localTime: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, + fallbackValue: params[param.key] ?? param.default, }); } diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts index 384f3d17..af3b910d 100644 --- a/apps/web/src/animation/index.ts +++ b/apps/web/src/animation/index.ts @@ -16,31 +16,14 @@ export { setChannel, splitAnimationsAtTime, updateScalarKeyframeCurve, - upsertElementKeyframe, upsertPathKeyframe, } from "./keyframes"; export { getElementLocalTime, resolveAnimationPathValueAtTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, } from "./resolve"; -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; - export { getElementKeyframes, getKeyframeById, @@ -75,15 +58,6 @@ export { resolveEffectParamsAtTime, } from "./effect-param-channel"; -export { - isAnimationPath, - coerceAnimationValueForParam, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - export { getGroupKeyframesAtTime, hasGroupKeyframeAtTime, @@ -94,3 +68,7 @@ export { type EasingMode, getEasingModeForKind, } from "./binding-values"; + +export { + isAnimationPath, +} from "./path"; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts index 277bfbb7..6bab66c4 100644 --- a/apps/web/src/animation/interpolation.ts +++ b/apps/web/src/animation/interpolation.ts @@ -8,13 +8,13 @@ import type { ScalarAnimationKey, ScalarSegmentType, } from "@/animation/types"; -import { clamp } from "@/utils/math"; import { getBezierPoint, getDefaultLeftHandle, getDefaultRightHandle, solveBezierProgressForTime, } from "./bezier"; +import { clamp } from "@/utils/math"; function byTimeAscending({ leftTime, diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts index 231aa5f2..e65db881 100644 --- a/apps/web/src/animation/keyframe-query.ts +++ b/apps/web/src/animation/keyframe-query.ts @@ -13,7 +13,7 @@ import { getChannelValueAtTime, getScalarSegmentInterpolation, } from "./interpolation"; -import { isAnimationPath } from "./target-resolver"; +import { isAnimationPath } from "./path"; function getBindingFallbackValue({ channel, diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index 12dc777d..ff0cef8f 100644 --- a/apps/web/src/animation/keyframes.ts +++ b/apps/web/src/animation/keyframes.ts @@ -4,7 +4,6 @@ import type { AnimationChannel, AnimationInterpolation, AnimationPath, - AnimationPropertyPath, AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, @@ -14,7 +13,6 @@ import type { ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/animation/types"; -import { generateUUID } from "@/utils/id"; import { cloneAnimationBinding, createAnimationBinding, @@ -31,10 +29,7 @@ import { getScalarSegmentInterpolation, normalizeChannel, } from "./interpolation"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, -} from "./property-registry"; +import { generateUUID } from "@/utils/id"; function isNearlySameTime({ leftTime, @@ -527,47 +522,6 @@ export function upsertPathKeyframe({ }); } -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return upsertPathKeyframe({ - animations, - propertyPath, - time, - value: coercedValue, - interpolation, - keyframeId, - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - coerceValue: ({ value: nextValue }) => - coerceAnimationValueForProperty({ - propertyPath, - value: nextValue, - }), - }); -} - export function upsertKeyframe({ channel, time, diff --git a/apps/web/src/animation/path.ts b/apps/web/src/animation/path.ts new file mode 100644 index 00000000..69e5e228 --- /dev/null +++ b/apps/web/src/animation/path.ts @@ -0,0 +1,22 @@ +import type { AnimationPath, AnimationPropertyPath } from "@/animation/types"; +import { ANIMATION_PROPERTY_PATHS } from "./types"; +import { isEffectParamPath } from "./effect-param-channel"; +import { isGraphicParamPath } from "./graphic-param-channel"; + +const ANIMATION_PROPERTY_PATH_SET = new Set(ANIMATION_PROPERTY_PATHS); + +export function isAnimationPropertyPath( + propertyPath: string, +): propertyPath is AnimationPropertyPath { + return ANIMATION_PROPERTY_PATH_SET.has(propertyPath); +} + +export function isAnimationPath( + propertyPath: string, +): propertyPath is AnimationPath { + return ( + isAnimationPropertyPath(propertyPath) || + isGraphicParamPath(propertyPath) || + isEffectParamPath(propertyPath) + ); +} diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts index 1b51c126..19685cea 100644 --- a/apps/web/src/animation/resolve.ts +++ b/apps/web/src/animation/resolve.ts @@ -1,12 +1,8 @@ import type { - AnimationColorPropertyPath, - AnimationNumericPropertyPath, AnimationPath, - AnimationPropertyPath, AnimationValueForPath, ElementAnimations, } from "@/animation/types"; -import type { Transform } from "@/rendering"; import { type AnimationComponentValue, composeAnimationValue, @@ -37,107 +33,6 @@ export function getElementLocalTime({ return localTime; } -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: { - x: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionX", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.x, - }), - y: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionY", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.y, - }), - }, - scaleX: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleX", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleY", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.rotate", - localTime: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath: "opacity", - localTime: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationNumericPropertyPath; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationColorPropertyPath; - localTime: number; -}): string { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} - export function resolveAnimationPathValueAtTime({ animations, propertyPath, diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index 3afddfa1..7e241994 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,5 +1,3 @@ -import type { ParamValues } from "@/params"; - export const ANIMATION_PROPERTY_PATHS = [ "transform.positionX", "transform.positionY", @@ -50,7 +48,13 @@ export interface AnimationPropertyValueMap { "background.offsetY": number; "background.cornerRadius": number; } -export type DynamicAnimationPathValue = ParamValues[string]; +export type DynamicAnimationPathValue = number | string | boolean; + +export interface NumericSpec { + min?: number; + max?: number; + step?: number; +} export type AnimationValueForPath = TPath extends AnimationPropertyPath ? AnimationPropertyValueMap[TPath] diff --git a/apps/web/src/animation/values.ts b/apps/web/src/animation/values.ts new file mode 100644 index 00000000..7733ec4c --- /dev/null +++ b/apps/web/src/animation/values.ts @@ -0,0 +1,61 @@ +import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + ElementAnimations, +} from "./types"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath: "opacity", + localTime: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationNumericPropertyPath; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationColorPropertyPath; + localTime: number; +}): string { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts index e05b49fa..c19bd526 100644 --- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -1,7 +1,6 @@ import { EditorCore } from "@/core"; import { getKeyframeAtTime, - resolveAnimationTarget, updateScalarKeyframeCurve, upsertPathKeyframe, } from "@/animation"; @@ -9,6 +8,7 @@ import { Command, type CommandResult } from "@/commands/base-command"; import type { KeyframeClipboardItem } from "@/clipboard"; import type { SceneTracks, TimelineElement } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { generateUUID } from "@/utils/id"; function pasteKeyframesIntoElement({ diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts index ed917406..07abe2be 100644 --- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts @@ -2,12 +2,12 @@ import { EditorCore } from "@/core"; import { hasKeyframesForPath, removeElementKeyframe, - resolveAnimationTarget, } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath, AnimationValue } from "@/animation/types"; import type { SceneTracks, TimelineElement } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; function removeKeyframeAndPersist({ element, diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts index ccbdbc5d..aa04e2bc 100644 --- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -1,9 +1,10 @@ import { EditorCore } from "@/core"; -import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation"; +import { retimeElementKeyframe } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; export class RetimeKeyframeCommand extends Command { private savedState: SceneTracks | null = null; diff --git a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts index 4a3db39d..51f10ee9 100644 --- a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts +++ b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -1,10 +1,10 @@ import { EditorCore } from "@/core"; import { - resolveAnimationTarget, updateScalarKeyframeCurve, } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, ScalarCurveKeyframePatch, diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index eae8f039..7bd1b2e5 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -2,11 +2,11 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; import { buildEffectParamPath, - resolveAnimationTarget, upsertPathKeyframe, } from "@/animation"; import { updateElementInSceneTracks } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationInterpolation } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts index 30514146..15c18bfc 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -1,8 +1,9 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; -import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation"; +import { upsertPathKeyframe } from "@/animation"; import { updateElementInSceneTracks } from "@/timeline"; import type { SceneTracks } from "@/timeline"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, AnimationInterpolation, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index 734b514f..80fc171b 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -1,95 +1,95 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedColor: string; - buildBaseUpdates: ({ value }: { value: string }) => Partial; -}) { - const editor = useEditor(); - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const onChange = ({ color }: { color: string }) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: color, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], - }); - }; - - const onChangeEnd = () => editor.timeline.commitPreview(); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, - ], - }); - }; - - return { - isKeyframedAtTime, - hasAnimatedKeyframes, - keyframeIdAtTime, - onChange, - onChangeEnd, - toggleKeyframe, - }; -} +import { useEditor } from "@/editor/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import { upsertElementKeyframe } from "@/timeline/animation-properties"; + +export function useKeyframedColorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedColor, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedColor: string; + buildBaseUpdates: ({ value }: { value: string }) => Partial; +}) { + const editor = useEditor(); + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const onChange = ({ color }: { color: string }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: color, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], + }); + }; + + const onChangeEnd = () => editor.timeline.commitPreview(); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, + ], + }); + }; + + return { + isKeyframedAtTime, + hasAnimatedKeyframes, + keyframeIdAtTime, + onChange, + onChangeEnd, + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 9dd39768..32222b7a 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -1,174 +1,174 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedNumberProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - displayValue, - parse, - valueAtPlayhead, - step, - buildBaseUpdates, - buildAdditionalKeyframes, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - displayValue: string; - parse: (input: string) => number | null; - valueAtPlayhead: number; - step?: number; - buildBaseUpdates: ({ value }: { value: number }) => Partial; - buildAdditionalKeyframes?: ({ - value, - }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; -}) { - const editor = useEditor(); - const snapValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - const updatedAnimations = [ - { propertyPath, value: nextValue }, - ...additionalKeyframes, - ].reduce( - (currentAnimations, keyframe) => - upsertElementKeyframe({ - animations: currentAnimations, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - }), - animations, - ); - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { animations: updatedAnimations }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - const propertyDraft = usePropertyDraft({ - displayValue, - parse: (input) => { - const parsedValue = parse(input); - return parsedValue === null ? null : snapValue(parsedValue); - }, - onPreview: (value) => previewValue({ value }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: snapValue(valueAtPlayhead), - }, - ], - }); - }; - - const commitValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: nextValue }, - ...additionalKeyframes.map((keyframe) => ({ - trackId, - elementId, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - })), - ], - }); - return; - } - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - patch: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - return { - ...propertyDraft, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitValue, - }; -} +import { useEditor } from "@/editor/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import { upsertElementKeyframe } from "@/timeline/animation-properties"; +import { snapToStep } from "@/utils/math"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedNumberProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + displayValue, + parse, + valueAtPlayhead, + step, + buildBaseUpdates, + buildAdditionalKeyframes, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + displayValue: string; + parse: (input: string) => number | null; + valueAtPlayhead: number; + step?: number; + buildBaseUpdates: ({ value }: { value: number }) => Partial; + buildAdditionalKeyframes?: ({ + value, + }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; +}) { + const editor = useEditor(); + const snapValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + const updatedAnimations = [ + { propertyPath, value: nextValue }, + ...additionalKeyframes, + ].reduce( + (currentAnimations, keyframe) => + upsertElementKeyframe({ + animations: currentAnimations, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + }), + animations, + ); + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { animations: updatedAnimations }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + const propertyDraft = usePropertyDraft({ + displayValue, + parse: (input) => { + const parsedValue = parse(input); + return parsedValue === null ? null : snapValue(parsedValue); + }, + onPreview: (value) => previewValue({ value }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: snapValue(valueAtPlayhead), + }, + ], + }); + }; + + const commitValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: nextValue }, + ...additionalKeyframes.map((keyframe) => ({ + trackId, + elementId, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + })), + ], + }); + return; + } + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + patch: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + return { + ...propertyDraft, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitValue, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index b0b6f01a..18e027c7 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -3,16 +3,18 @@ import { useEditor } from "@/editor/use-editor"; import { buildGraphicParamPath, - coerceAnimationValueForParam, getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, hasKeyframesForPath, upsertPathKeyframe, } from "@/animation"; import type { ElementAnimations, } from "@/animation/types"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamValueKind, +} from "@/animation/animated-params"; import type { ParamDefinition } from "@/params"; import type { TimelineElement } from "@/timeline"; @@ -79,12 +81,12 @@ export function useKeyframedParamProperty({ propertyPath, time: localTime, value, - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param, }), coerceValue: ({ value: nextValue }) => - coerceAnimationValueForParam({ + coerceAnimationParamValue({ param, value: nextValue, }), diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 6f54c5aa..a2bf7451 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -24,9 +24,9 @@ import type { } from "@/animation/types"; import { getElementLocalTime, - resolveAnimationTarget, resolveAnimationPathValueAtTime, } from "@/animation"; +import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { lastFrameTime } from "opencut-wasm"; import { BatchCommand } from "@/commands"; import { diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx index 60599772..e0ae0236 100644 --- a/apps/web/src/graphics/components/graphic-tab.tsx +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -1,238 +1,241 @@ -"use client"; - -import { useRef } from "react"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { - useKeyframedParamProperty, - type KeyframedParamPropertyResult, -} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; -import { resolveGraphicParamsAtTime } from "@/animation"; -import type { ParamDefinition, ParamValues } from "@/params"; -import type { GraphicElement } from "@/timeline"; -import { graphicsRegistry, registerDefaultGraphics } from "@/graphics"; -import { useElementPreview } from "@/timeline/hooks/use-element-preview"; -import { useEditor } from "@/editor/use-editor"; -import { - Section, - SectionContent, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -registerDefaultGraphics(); - -const DEFAULT_STROKE_WIDTH = 2; - -export function GraphicTab({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - - const shapeParams = definition.params.filter((p) => p.group !== "stroke"); - const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); - - return ( -
-
- - {definition.name} - - - - {shapeParams.map((param) => ( - - ))} - - -
- {hasStrokeParams && } -
- ); -} - -function StrokeSection({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - const strokeParams = definition.params.filter((p) => p.group === "stroke"); - const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); - const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; - - const toggleStroke = () => { - if (isStrokeEnabled) { - lastStrokeWidth.current = Number( - element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, - ); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { params: { ...element.params, strokeWidth: 0 } }, - }, - ], - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - params: { - ...element.params, - strokeWidth: lastStrokeWidth.current, - }, - }, - }, - ], - }); - } - }; - - return ( -
- { - event.stopPropagation(); - toggleStroke(); - }} - > - - - } - > - Stroke - - - - {strokeParams.map((param) => ( - - ))} - - -
- ); -} - -function AnimatedGraphicParamField({ - param, - trackId, - element, - localTime, - isPlayheadWithinElementRange, - resolvedParams, -}: { - param: ParamDefinition; - trackId: string; - element: GraphicElement; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedParams: ParamValues; -}) { - const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty( - { - param, - trackId, - elementId: element.id, - animations: element.animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue: resolvedParams[param.key] ?? param.default, - buildBaseUpdates: ({ value }) => ({ - params: { - ...element.params, - [param.key]: value, - }, - }), - }, - ); - - return ( - - ); -} +"use client"; + +import { useRef } from "react"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { + useKeyframedParamProperty, + type KeyframedParamPropertyResult, +} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; +import type { ParamDefinition, ParamValues } from "@/params"; +import type { GraphicElement } from "@/timeline"; +import { + graphicsRegistry, + registerDefaultGraphics, + resolveGraphicElementParamsAtTime, +} from "@/graphics"; +import { useElementPreview } from "@/timeline/hooks/use-element-preview"; +import { useEditor } from "@/editor/use-editor"; +import { + Section, + SectionContent, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +registerDefaultGraphics(); + +const DEFAULT_STROKE_WIDTH = 2; + +export function GraphicTab({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicElementParamsAtTime({ + element: liveElement, + localTime, + }); + + const shapeParams = definition.params.filter((p) => p.group !== "stroke"); + const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); + + return ( +
+
+ + {definition.name} + + + + {shapeParams.map((param) => ( + + ))} + + +
+ {hasStrokeParams && } +
+ ); +} + +function StrokeSection({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicElementParamsAtTime({ + element: liveElement, + localTime, + }); + const strokeParams = definition.params.filter((p) => p.group === "stroke"); + const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); + const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; + + const toggleStroke = () => { + if (isStrokeEnabled) { + lastStrokeWidth.current = Number( + element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, + ); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { params: { ...element.params, strokeWidth: 0 } }, + }, + ], + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { + params: { + ...element.params, + strokeWidth: lastStrokeWidth.current, + }, + }, + }, + ], + }); + } + }; + + return ( +
+ { + event.stopPropagation(); + toggleStroke(); + }} + > + + + } + > + Stroke + + + + {strokeParams.map((param) => ( + + ))} + + +
+ ); +} + +function AnimatedGraphicParamField({ + param, + trackId, + element, + localTime, + isPlayheadWithinElementRange, + resolvedParams, +}: { + param: ParamDefinition; + trackId: string; + element: GraphicElement; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedParams: ParamValues; +}) { + const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty( + { + param, + trackId, + elementId: element.id, + animations: element.animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue: resolvedParams[param.key] ?? param.default, + buildBaseUpdates: ({ value }) => ({ + params: { + ...element.params, + [param.key]: value, + }, + }), + }, + ); + + return ( + + ); +} diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts index c69a4ab0..3f277d40 100644 --- a/apps/web/src/graphics/index.ts +++ b/apps/web/src/graphics/index.ts @@ -1,3 +1,5 @@ +import { resolveGraphicParamsAtTime } from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; import { buildDefaultParamValues } from "@/params/registry"; import type { ParamValues } from "@/params"; import { graphicsRegistry } from "./registry"; @@ -68,6 +70,28 @@ export function resolveGraphicParams( }; } +export function resolveGraphicElementParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + return resolveGraphicParamsAtTime({ + params: resolveGraphicParams(definition, element.params), + definitions: definition.params, + animations: element.animations, + localTime, + }); +} + export function buildGraphicPreviewUrl({ definitionId, params, diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx index bc27c724..1d599b3b 100644 --- a/apps/web/src/preview/components/text-edit-overlay.tsx +++ b/apps/web/src/preview/components/text-edit-overlay.tsx @@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline"; import { DEFAULTS } from "@/timeline/defaults"; import { getElementLocalTime, - resolveTransformAtTime, } from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { resolveTextLayout } from "@/text/primitives"; export function TextEditOverlay({ diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts index e9d251e3..67f677f6 100644 --- a/apps/web/src/preview/controllers/transform-handle-controller.ts +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils"; import { getElementLocalTime, hasKeyframesForPath, - resolveTransformAtTime, setChannel, } from "@/animation"; import type { ElementAnimations } from "@/animation/types"; import type { Transform } from "@/rendering"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import type { ElementRef, SceneTracks, diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts index ac556181..0afc6e4e 100644 --- a/apps/web/src/preview/element-bounds.ts +++ b/apps/web/src/preview/element-bounds.ts @@ -1,308 +1,308 @@ -import type { SceneTracks, TimelineElement } from "@/timeline"; -import type { MediaAsset } from "@/media/types"; -import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size"; -import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; -import { measureTextElement } from "@/text/measure-element"; -import { - getElementLocalTime, - resolveTransformAtTime, -} from "@/animation"; - -export interface ElementBounds { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; -} - -export interface ElementWithBounds { - trackId: string; - elementId: string; - element: TimelineElement; - bounds: ElementBounds; -} - -function getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth, - sourceHeight, - transform, -}: { - canvasWidth: number; - canvasHeight: number; - sourceWidth: number; - sourceHeight: number; - transform: { - scaleX: number; - scaleY: number; - position: { x: number; y: number }; - rotate: number; - }; -}): ElementBounds { - const containScale = Math.min( - canvasWidth / sourceWidth, - canvasHeight / sourceHeight, - ); - const scaledWidth = sourceWidth * containScale * transform.scaleX; - const scaledHeight = sourceHeight * containScale * transform.scaleY; - const cx = canvasWidth / 2 + transform.position.x; - const cy = canvasHeight / 2 + transform.position.y; - - return { - cx, - cy, - width: scaledWidth, - height: scaledHeight, - rotation: transform.rotate, - }; -} - -function getTransformedRectBounds({ - canvasWidth, - canvasHeight, - rect, - transform, -}: { - canvasWidth: number; - canvasHeight: number; - rect: { left: number; top: number; width: number; height: number }; - transform: { - scaleX: number; - scaleY: number; - position: { x: number; y: number }; - rotate: number; - }; -}): ElementBounds { - const localCenterX = rect.left + rect.width / 2; - const localCenterY = rect.top + rect.height / 2; - const scaledCenterX = localCenterX * transform.scaleX; - const scaledCenterY = localCenterY * transform.scaleY; - const rotationRad = (transform.rotate * Math.PI) / 180; - const cos = Math.cos(rotationRad); - const sin = Math.sin(rotationRad); - return { - cx: - canvasWidth / 2 + - transform.position.x + - scaledCenterX * cos - - scaledCenterY * sin, - cy: - canvasHeight / 2 + - transform.position.y + - scaledCenterX * sin + - scaledCenterY * cos, - width: rect.width * transform.scaleX, - height: rect.height * transform.scaleY, - rotation: transform.rotate, - }; -} - -/** - * Bounds policy: bounds reflect base content geometry (text glyphs + background, - * sticker/image/video content area) and base transform. Post-effect spill (blur, - * glow) and mask-clipped regions are intentionally excluded — handles manipulate - * the canonical element geometry, not visual effect output. - */ -function getElementBounds({ - element, - canvasSize, - mediaAsset, - localTime, -}: { - element: TimelineElement; - canvasSize: { width: number; height: number }; - mediaAsset?: MediaAsset | null; - localTime: number; -}): ElementBounds | null { - if (element.type === "audio" || element.type === "effect") return null; - if ("hidden" in element && element.hidden) return null; - - const { width: canvasWidth, height: canvasHeight } = canvasSize; - - if (element.type === "video" || element.type === "image") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - const sourceWidth = mediaAsset?.width ?? canvasWidth; - const sourceHeight = mediaAsset?.height ?? canvasHeight; - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth, - sourceHeight, - transform, - }); - } - - if (element.type === "sticker") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, - sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, - transform, - }); - } - - if (element.type === "graphic") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, - sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, - transform, - }); - } - - if (element.type === "text") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - - const measured = measureTextElement({ - element, - canvasHeight, - localTime, - ctx, - }); - - return getTransformedRectBounds({ - canvasWidth, - canvasHeight, - rect: measured.visualRect, - transform, - }); - } - - return null; -} - -export const ROTATION_HANDLE_OFFSET = 24; - -export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -export type Edge = "right" | "left" | "bottom"; - -export function getCornerPosition({ - bounds, - corner, -}: { - bounds: ElementBounds; - corner: Corner; -}): { x: number; y: number } { - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; - const localY = - corner === "top-left" || corner === "top-right" ? -halfH : halfH; - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getEdgeHandlePosition({ - bounds, - edge, -}: { - bounds: ElementBounds; - edge: Edge; -}): { x: number; y: number } { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; - const localY = edge === "bottom" ? halfHeight : 0; - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, -}: { - tracks: SceneTracks; - currentTime: number; - canvasSize: { width: number; height: number }; - mediaAssets: MediaAsset[]; -}): ElementWithBounds[] { - const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - const orderedTracks = [ - ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), - ...(!tracks.main.hidden ? [tracks.main] : []), - ].reverse(); - - const result: ElementWithBounds[] = []; - - for (const track of orderedTracks) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .filter( - (element) => - currentTime >= element.startTime && - currentTime < element.startTime + element.duration, - ) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); - - for (const element of elements) { - const localTime = getElementLocalTime({ - timelineTime: currentTime, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const mediaAsset = - element.type === "video" || element.type === "image" - ? mediaMap.get(element.mediaId) - : undefined; - const bounds = getElementBounds({ - element, - canvasSize, - mediaAsset, - localTime, - }); - if (bounds) { - result.push({ - trackId: track.id, - elementId: element.id, - element, - bounds, - }); - } - } - } - - return result; -} +import type { SceneTracks, TimelineElement } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size"; +import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; +import { measureTextElement } from "@/text/measure-element"; +import { + getElementLocalTime, +} from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; + +export interface ElementBounds { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; +} + +export interface ElementWithBounds { + trackId: string; + elementId: string; + element: TimelineElement; + bounds: ElementBounds; +} + +function getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + sourceWidth: number; + sourceHeight: number; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const containScale = Math.min( + canvasWidth / sourceWidth, + canvasHeight / sourceHeight, + ); + const scaledWidth = sourceWidth * containScale * transform.scaleX; + const scaledHeight = sourceHeight * containScale * transform.scaleY; + const cx = canvasWidth / 2 + transform.position.x; + const cy = canvasHeight / 2 + transform.position.y; + + return { + cx, + cy, + width: scaledWidth, + height: scaledHeight, + rotation: transform.rotate, + }; +} + +function getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + rect: { left: number; top: number; width: number; height: number }; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const localCenterX = rect.left + rect.width / 2; + const localCenterY = rect.top + rect.height / 2; + const scaledCenterX = localCenterX * transform.scaleX; + const scaledCenterY = localCenterY * transform.scaleY; + const rotationRad = (transform.rotate * Math.PI) / 180; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); + return { + cx: + canvasWidth / 2 + + transform.position.x + + scaledCenterX * cos - + scaledCenterY * sin, + cy: + canvasHeight / 2 + + transform.position.y + + scaledCenterX * sin + + scaledCenterY * cos, + width: rect.width * transform.scaleX, + height: rect.height * transform.scaleY, + rotation: transform.rotate, + }; +} + +/** + * Bounds policy: bounds reflect base content geometry (text glyphs + background, + * sticker/image/video content area) and base transform. Post-effect spill (blur, + * glow) and mask-clipped regions are intentionally excluded — handles manipulate + * the canonical element geometry, not visual effect output. + */ +function getElementBounds({ + element, + canvasSize, + mediaAsset, + localTime, +}: { + element: TimelineElement; + canvasSize: { width: number; height: number }; + mediaAsset?: MediaAsset | null; + localTime: number; +}): ElementBounds | null { + if (element.type === "audio" || element.type === "effect") return null; + if ("hidden" in element && element.hidden) return null; + + const { width: canvasWidth, height: canvasHeight } = canvasSize; + + if (element.type === "video" || element.type === "image") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const sourceWidth = mediaAsset?.width ?? canvasWidth; + const sourceHeight = mediaAsset?.height ?? canvasHeight; + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, + }); + } + + if (element.type === "sticker") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, + sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, + transform, + }); + } + + if (element.type === "graphic") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, + sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, + transform, + }); + } + + if (element.type === "text") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + const measured = measureTextElement({ + element, + canvasHeight, + localTime, + ctx, + }); + + return getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect: measured.visualRect, + transform, + }); + } + + return null; +} + +export const ROTATION_HANDLE_OFFSET = 24; + +export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +export type Edge = "right" | "left" | "bottom"; + +export function getCornerPosition({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): { x: number; y: number } { + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + const localY = + corner === "top-left" || corner === "top-right" ? -halfH : halfH; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getEdgeHandlePosition({ + bounds, + edge, +}: { + bounds: ElementBounds; + edge: Edge; +}): { x: number; y: number } { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; + const localY = edge === "bottom" ? halfHeight : 0; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, +}: { + tracks: SceneTracks; + currentTime: number; + canvasSize: { width: number; height: number }; + mediaAssets: MediaAsset[]; +}): ElementWithBounds[] { + const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); + const orderedTracks = [ + ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), + ...(!tracks.main.hidden ? [tracks.main] : []), + ].reverse(); + + const result: ElementWithBounds[] = []; + + for (const track of orderedTracks) { + const elements = track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .filter( + (element) => + currentTime >= element.startTime && + currentTime < element.startTime + element.duration, + ) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); + + for (const element of elements) { + const localTime = getElementLocalTime({ + timelineTime: currentTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const mediaAsset = + element.type === "video" || element.type === "image" + ? mediaMap.get(element.mediaId) + : undefined; + const bounds = getElementBounds({ + element, + canvasSize, + mediaAsset, + localTime, + }); + if (bounds) { + result.push({ + trackId: track.id, + elementId: element.id, + element, + bounds, + }); + } + } + } + + return result; +} diff --git a/apps/web/src/rendering/NOTES.md b/apps/web/src/rendering/NOTES.md new file mode 100644 index 00000000..38055c14 --- /dev/null +++ b/apps/web/src/rendering/NOTES.md @@ -0,0 +1,27 @@ +# Notes + +## `Transform` is misplaced + +`Transform` is currently defined in `apps/web/src/rendering/index.ts`. It's +exported from the rendering domain because rendering happens to be one of its +consumers — but every other domain that touches scene geometry consumes it too +(`@/timeline`, `@/preview`, `@/animation/values`, `@/text`, etc.). It's not +"of" rendering any more than `Vector2` would be "of" math. + +`Transform` is closer to a primitive than a domain concept. It's infrastructure: +a small value type with no behavior, no dependencies, no domain-specific +invariants. Anything that wants to position something on a 2D canvas needs it. + +The codebase should be refactored so it's defined lower-level, rather than +sitting in a domain. Candidate homes: + +- `apps/web/src/primitives/transform.ts` (new dir for these value types) +- `apps/web/src/geometry/transform.ts` if `Vector2` and friends end up there +- `apps/web/src/rendering/transform.ts` is acceptable only if `@/rendering` +becomes a primitives folder rather than a domain (it's borderline today — +it also exports `BlendMode`, which has the same problem). + +Side effect of fixing this: `@/rendering/animation-values.ts` (which exists +only because `Transform` lives next to it) can move into `@/animation/values.ts` +alongside the other resolve-at-time helpers, since `Transform` would no longer +pull in a domain dependency. \ No newline at end of file diff --git a/apps/web/src/rendering/animation-values.ts b/apps/web/src/rendering/animation-values.ts new file mode 100644 index 00000000..8e8b4528 --- /dev/null +++ b/apps/web/src/rendering/animation-values.ts @@ -0,0 +1,49 @@ +import type { ElementAnimations } from "@/animation/types"; +import { resolveAnimationPathValueAtTime } from "@/animation"; +import type { Transform } from "./index"; + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: { + x: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionX", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.x, + }), + y: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionY", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.y, + }), + }, + scaleX: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleX", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleX, + }), + scaleY: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleY", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleY, + }), + rotate: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.rotate", + localTime: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx index 3e0db46c..48b1d0e5 100644 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ b/apps/web/src/rendering/components/blending-tab.tsx @@ -27,7 +27,7 @@ import { RainDropIcon } from "@hugeicons/core-free-icons"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/animation"; +import { resolveOpacityAtTime } from "@/animation/values"; import { DEFAULTS } from "@/timeline/defaults"; import { isPropertyAtDefault } from "./transform-tab"; diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx index 60d4c7df..f4d5a183 100644 --- a/apps/web/src/rendering/components/transform-tab.tsx +++ b/apps/web/src/rendering/components/transform-tab.tsx @@ -20,8 +20,8 @@ import { import { getGroupKeyframesAtTime, hasGroupKeyframeAtTime, - resolveTransformAtTime, } from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { DEFAULTS } from "@/timeline/defaults"; import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 5495ba3b..809f7103 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -1,11 +1,5 @@ import { mediaTimeToSeconds } from "opencut-wasm"; -import { - getElementLocalTime, - resolveColorAtTime, - resolveGraphicParamsAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "@/animation"; +import { getElementLocalTime } from "@/animation"; import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel"; import { buildGaussianBlurPasses, @@ -14,11 +8,16 @@ import { import { effectsRegistry, resolveEffectPasses } from "@/effects"; import type { Effect, EffectPass } from "@/effects/types"; import { getSourceTimeAtClipTime } from "@/retime"; -import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; +import { + DEFAULT_GRAPHIC_SOURCE_SIZE, + resolveGraphicElementParamsAtTime, +} from "@/graphics"; import { getTextMeasurementContext, measureTextElement, } from "@/text/measure-element"; +import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { videoCache } from "@/services/video-cache/service"; import type { CanvasRenderer } from "./canvas-renderer"; import type { AnyBaseNode } from "./nodes/base-node"; @@ -113,7 +112,8 @@ function resolveEffectPassGroups({ .filter((effect) => effect.enabled) .map((effect) => { const resolvedParams = resolveEffectParamsAtTime({ - effect, + effectId: effect.id, + params: effect.params, animations, localTime, }); @@ -304,7 +304,7 @@ function resolveGraphicNode({ return { ...visualState, - resolvedParams: resolveGraphicParamsAtTime({ + resolvedParams: resolveGraphicElementParamsAtTime({ element: node.params, localTime: visualState.localTime, }), diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx index bf14efba..0f0b274a 100644 --- a/apps/web/src/text/components/text-tab.tsx +++ b/apps/web/src/text/components/text-tab.tsx @@ -30,7 +30,10 @@ import { useKeyframedNumberProperty } from "@/components/editor/panels/propertie import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; import { isPropertyAtDefault } from "@/rendering/components/transform-tab"; -import { resolveColorAtTime, resolveNumberAtTime } from "@/animation"; +import { + resolveColorAtTime, + resolveNumberAtTime, +} from "@/animation/values"; import { HugeiconsIcon } from "@hugeicons/react"; import { MinusSignIcon, diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts index 3a49696f..ec14421b 100644 --- a/apps/web/src/text/measure-element.ts +++ b/apps/web/src/text/measure-element.ts @@ -1,7 +1,7 @@ import { CORNER_RADIUS_MIN } from "@/text/background"; -import { resolveNumberAtTime } from "@/animation"; import { DEFAULTS } from "@/timeline/defaults"; import type { TextBackground, TextElement } from "@/timeline"; +import { resolveNumberAtTime } from "@/animation/values"; import { getTextVisualRect, } from "./layout"; diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/timeline/animation-properties.ts similarity index 90% rename from apps/web/src/animation/property-registry.ts rename to apps/web/src/timeline/animation-properties.ts index aacb9972..31479a58 100644 --- a/apps/web/src/animation/property-registry.ts +++ b/apps/web/src/timeline/animation-properties.ts @@ -3,10 +3,15 @@ import type { AnimationInterpolation, AnimationPropertyPath, AnimationValue, + ElementAnimations, + NumericSpec, } from "@/animation/types"; -import { parseColorToLinearRgba } from "./binding-values"; -import type { TimelineElement } from "@/timeline"; +import { upsertPathKeyframe } from "@/animation"; +import { parseColorToLinearRgba } from "@/animation/binding-values"; +import { isAnimationPropertyPath } from "@/animation/path"; import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; +import { snapToStep } from "@/utils/math"; +import type { TimelineElement } from "@/timeline"; import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, @@ -17,13 +22,6 @@ import { } from "@/timeline/element-utils"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; import { DEFAULTS } from "@/timeline/defaults"; -import { snapToStep } from "@/utils/math"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} export interface AnimationPropertyDefinition { kind: AnimationBindingKind; @@ -313,12 +311,6 @@ const ANIMATION_PROPERTY_REGISTRY: Record< }), }; -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - export function getAnimationPropertyDefinition({ propertyPath, }: { @@ -349,6 +341,7 @@ export function getElementBaseValueForProperty({ if (!definition.supportsElement({ element })) { return null; } + return definition.getValue({ element }); } @@ -366,6 +359,7 @@ export function withElementBaseValueForProperty({ if (coercedValue === null || !definition.supportsElement({ element })) { return element; } + return definition.setValue({ element, value: coercedValue }); } @@ -388,3 +382,44 @@ export function coerceAnimationValueForProperty({ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); return propertyDefinition.coerceValue({ value }); } + +export function upsertElementKeyframe({ + animations, + propertyPath, + time, + value, + interpolation, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; +}): ElementAnimations | undefined { + const coercedValue = coerceAnimationValueForProperty({ + propertyPath, + value, + }); + if (coercedValue === null) { + return animations; + } + + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return upsertPathKeyframe({ + animations, + propertyPath, + time, + value: coercedValue, + interpolation, + keyframeId, + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + coerceValue: ({ value: nextValue }) => + coerceAnimationValueForProperty({ + propertyPath, + value: nextValue, + }), + }); +} diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts similarity index 100% rename from apps/web/src/animation/timeline-snap-points.ts rename to apps/web/src/timeline/animation-snap-points.ts diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/timeline/animation-targets.ts similarity index 64% rename from apps/web/src/animation/target-resolver.ts rename to apps/web/src/timeline/animation-targets.ts index 32e7917c..f9d53fcc 100644 --- a/apps/web/src/animation/target-resolver.ts +++ b/apps/web/src/timeline/animation-targets.ts @@ -3,30 +3,32 @@ import type { AnimationInterpolation, AnimationPath, AnimationValue, + NumericSpec, } from "@/animation/types"; +import { + coerceAnimationParamValue, + getAnimationParamDefaultInterpolation, + getAnimationParamNumericRange, + getAnimationParamValueKind, +} from "@/animation/animated-params"; import { parseEffectParamPath, - isEffectParamPath, } from "@/animation/effect-param-channel"; import { - isGraphicParamPath, parseGraphicParamPath, } from "@/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/params"; import { effectsRegistry, registerDefaultEffects } from "@/effects"; import { getGraphicDefinition } from "@/graphics"; +import type { ParamDefinition } from "@/params"; import type { TimelineElement } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; +import { isAnimationPropertyPath } from "@/animation/path"; import { coerceAnimationValueForProperty, getAnimationPropertyDefinition, getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, withElementBaseValueForProperty, -} from "./property-registry"; -import { parseColorToLinearRgba } from "./binding-values"; +} from "./animation-properties"; export interface AnimationPathDescriptor { kind: AnimationBindingKind; @@ -37,79 +39,16 @@ export interface AnimationPathDescriptor { setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; } -export function getParamValueKind({ - param, -}: { - param: ParamDefinition; -}): AnimationBindingKind { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): AnimationInterpolation { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -function getParamNumericRange({ +// Number/discrete bindings expose a single component named "value" +// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry +// numeric ranges yet — revisit when one does. +function paramNumericRanges({ param, }: { param: ParamDefinition; }): Partial> | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - value: { - min: param.min, - max: param.max, - step: param.step, - }, - }; -} - -export function coerceAnimationValueForParam({ - param, - value, -}: { - param: ParamDefinition; - value: AnimationValue; -}): number | string | boolean | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; + const range = getAnimationParamNumericRange({ param }); + return range ? { value: range } : undefined; } function buildGraphicParamDescriptor({ @@ -132,13 +71,13 @@ function buildGraphicParamDescriptor({ } return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), getBaseValue: () => element.params[param.key] ?? param.default, setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); + const coercedValue = coerceAnimationParamValue({ param, value }); if (coercedValue === null) { return element; } @@ -180,13 +119,13 @@ function buildEffectParamDescriptor({ } return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), + kind: getAnimationParamValueKind({ param }), + defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), getBaseValue: () => effect.params[param.key] ?? param.default, setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); + const coercedValue = coerceAnimationParamValue({ param, value }); if (coercedValue === null) { return element; } @@ -210,16 +149,6 @@ function buildEffectParamDescriptor({ }; } -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - export function resolveAnimationTarget({ element, path, diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts index 4ef559ea..44df5aaf 100644 --- a/apps/web/src/timeline/audio-state.ts +++ b/apps/web/src/timeline/audio-state.ts @@ -1,5 +1,5 @@ import { hasKeyframesForPath } from "@/animation/keyframe-query"; -import { resolveNumberAtTime } from "@/animation/resolve"; +import { resolveNumberAtTime } from "@/animation/values"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; import type { TimelineElement } from "./types"; const DEFAULT_STEP_SECONDS = 1 / 60; diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 1071c6dd..35ee9cca 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -19,7 +19,7 @@ import { import { getBookmarkSnapPoints } from "../snap-source"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { Bookmark } from "@/timeline"; export interface BookmarkDragState { diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx index 1dbb3d1a..1e47dcc8 100644 --- a/apps/web/src/timeline/components/audio-tab.tsx +++ b/apps/web/src/timeline/components/audio-tab.tsx @@ -1,151 +1,151 @@ -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { isSourceAudioSeparated } from "@/timeline/audio-separation"; -import { DEFAULTS } from "@/timeline/defaults"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - isNearlyEqual, - snapToStep, -} from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/timeline"; -import { resolveNumberAtTime } from "@/animation"; -import { useEditor } from "@/editor/use-editor"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { VolumeHighIcon } from "@hugeicons/core-free-icons"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; - -const VOLUME_STEP = 0.1; -const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedVolume = resolveNumberAtTime({ - baseValue: element.volume ?? DEFAULTS.element.volume, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - const volume = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "volume", - localTime, - isPlayheadWithinElementRange, - displayValue: formatNumberForDisplay({ - value: resolvedVolume, - fractionDigits: VOLUME_FRACTION_DIGITS, - }), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ - value: snapToStep({ value: parsed, step: VOLUME_STEP }), - min: VOLUME_DB_MIN, - max: VOLUME_DB_MAX, - }); - }, - valueAtPlayhead: resolvedVolume, - step: VOLUME_STEP, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - const isSeparated = - element.type === "video" && isSourceAudioSeparated({ element }); - - return ( - <> - {isSeparated && ( -
-

Audio has been separated.

- -
- )} -
- - Audio - - - - - } - > - } - value={volume.displayValue} - onFocus={volume.onFocus} - onChange={volume.onChange} - onBlur={volume.onBlur} - dragSensitivity="slow" - scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} - onScrub={volume.scrubTo} - onScrubEnd={volume.commitScrub} - onReset={() => - volume.commitValue({ - value: DEFAULTS.element.volume, - }) - } - isDefault={isDefault} - suffix="dB" - /> - - - -
- - ); -} +import { Button } from "@/components/ui/button"; +import { NumberField } from "@/components/ui/number-field"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { isSourceAudioSeparated } from "@/timeline/audio-separation"; +import { DEFAULTS } from "@/timeline/defaults"; +import { + clamp, + formatNumberForDisplay, + getFractionDigitsForStep, + isNearlyEqual, + snapToStep, +} from "@/utils/math"; +import type { AudioElement, VideoElement } from "@/timeline"; +import { resolveNumberAtTime } from "@/animation/values"; +import { useEditor } from "@/editor/use-editor"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; +import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { VolumeHighIcon } from "@hugeicons/core-free-icons"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; + +const VOLUME_STEP = 0.1; +const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); + +export function AudioTab({ + element, + trackId, +}: { + element: AudioElement | VideoElement; + trackId: string; +}) { + const editor = useEditor(); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedVolume = resolveNumberAtTime({ + baseValue: element.volume ?? DEFAULTS.element.volume, + animations: element.animations, + propertyPath: "volume", + localTime, + }); + const volume = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "volume", + localTime, + isPlayheadWithinElementRange, + displayValue: formatNumberForDisplay({ + value: resolvedVolume, + fractionDigits: VOLUME_FRACTION_DIGITS, + }), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) { + return null; + } + + return clamp({ + value: snapToStep({ value: parsed, step: VOLUME_STEP }), + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + }); + }, + valueAtPlayhead: resolvedVolume, + step: VOLUME_STEP, + buildBaseUpdates: ({ value }) => ({ + volume: value, + }), + }); + const isDefault = + volume.hasAnimatedKeyframes && isPlayheadWithinElementRange + ? isNearlyEqual({ + leftValue: resolvedVolume, + rightValue: DEFAULTS.element.volume, + }) + : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; + const isSeparated = + element.type === "video" && isSourceAudioSeparated({ element }); + + return ( + <> + {isSeparated && ( +
+

Audio has been separated.

+ +
+ )} +
+ + Audio + + + + + } + > + } + value={volume.displayValue} + onFocus={volume.onFocus} + onChange={volume.onChange} + onBlur={volume.onBlur} + dragSensitivity="slow" + scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} + onScrub={volume.scrubTo} + onScrubEnd={volume.commitScrub} + onReset={() => + volume.commitValue({ + value: DEFAULTS.element.volume, + }) + } + isDefault={isDefault} + suffix="dB" + /> + + + +
+ + ); +} diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts index 5260c046..d2b7d82f 100644 --- a/apps/web/src/timeline/controllers/playhead-controller.ts +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -8,7 +8,7 @@ import { } from "@/timeline/snapping"; import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import { getCenteredLineLeft, timelineTimeToPixels, diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts index 8677dd15..cdf502a1 100644 --- a/apps/web/src/timeline/controllers/resize-controller.ts +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -16,7 +16,7 @@ import { } from "@/timeline/snapping"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import { isRetimableElement, type SceneTracks, diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index 32c0a2f1..7f431e6e 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -7,7 +7,7 @@ import { } from "@/timeline/snapping"; import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { MoveGroup } from "./types"; export function snapGroupEdges({ From 56ca09692a308b22455420d11376ff8d41244c47 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 17:31:28 +0200 Subject: [PATCH 08/29] refactor: thinner hooks --- .../web/src/animation/timeline-snap-points.ts | 3 +- apps/web/src/app/brand/page.tsx | 536 ++++++------ apps/web/src/components/editable-timecode.tsx | 27 +- .../editor/panels/assets/draggable-item.tsx | 17 +- apps/web/src/core/managers/audio-manager.ts | 23 +- .../web/src/core/managers/playback-manager.ts | 33 +- .../web/src/core/managers/timeline-manager.ts | 11 +- apps/web/src/media/use-file-upload.ts | 14 +- apps/web/src/preview/components/toolbar.tsx | 12 +- .../preview-interaction-controller.ts | 583 +++++++++++++ .../transform-handle-controller.ts | 753 ++++++++++++++++ .../preview/hooks/use-preview-interaction.ts | 499 ++--------- .../preview/hooks/use-transform-handles.ts | 643 ++------------ apps/web/src/services/video-cache/service.ts | 45 +- .../bookmarks/components/bookmarks.tsx | 4 +- .../bookmarks/hooks/use-bookmark-drag.ts | 23 +- .../web/src/timeline/bookmarks/snap-source.ts | 3 +- apps/web/src/timeline/bookmarks/utils.ts | 5 +- apps/web/src/timeline/components/index.tsx | 57 +- .../timeline/components/timeline-element.tsx | 31 +- .../timeline/components/timeline-track.tsx | 26 +- .../controllers/drag-drop-controller.ts | 577 +++++++++++++ .../element-interaction-controller.ts | 702 +++++++++++++++ .../controllers/keyframe-drag-controller.ts | 358 ++++++++ .../controllers/playhead-controller.ts | 318 +++++++ .../timeline/controllers/resize-controller.ts | 360 ++++++++ .../timeline/controllers/seek-controller.ts | 210 +++++ .../timeline/controllers/zoom-controller.ts | 286 +++++++ apps/web/src/timeline/creation.ts | 14 +- apps/web/src/timeline/drag-data.ts | 48 -- apps/web/src/timeline/drag-source.ts | 40 + apps/web/src/timeline/element-snap-source.ts | 3 +- apps/web/src/timeline/group-move/snap.ts | 4 +- .../timeline/group-resize/compute-resize.ts | 218 +++-- apps/web/src/timeline/group-resize/types.ts | 27 +- .../hooks/element/use-element-interaction.ts | 807 ++---------------- .../hooks/element/use-keyframe-drag.ts | 358 +------- .../timeline/hooks/use-timeline-drag-drop.ts | 659 +------------- .../timeline/hooks/use-timeline-playhead.ts | 341 ++------ .../src/timeline/hooks/use-timeline-resize.ts | 364 ++------ .../src/timeline/hooks/use-timeline-seek.ts | 196 +---- .../src/timeline/hooks/use-timeline-zoom.ts | 283 +----- apps/web/src/timeline/playhead-snap-source.ts | 3 +- apps/web/src/timeline/snapping/resolve.ts | 3 +- apps/web/src/timeline/snapping/types.ts | 6 +- apps/web/src/timeline/types.ts | 17 + apps/web/src/wasm/media-time.ts | 61 ++ bun.lock | 8 +- 48 files changed, 5355 insertions(+), 4264 deletions(-) create mode 100644 apps/web/src/preview/controllers/preview-interaction-controller.ts create mode 100644 apps/web/src/preview/controllers/transform-handle-controller.ts create mode 100644 apps/web/src/timeline/controllers/drag-drop-controller.ts create mode 100644 apps/web/src/timeline/controllers/element-interaction-controller.ts create mode 100644 apps/web/src/timeline/controllers/keyframe-drag-controller.ts create mode 100644 apps/web/src/timeline/controllers/playhead-controller.ts create mode 100644 apps/web/src/timeline/controllers/resize-controller.ts create mode 100644 apps/web/src/timeline/controllers/seek-controller.ts create mode 100644 apps/web/src/timeline/controllers/zoom-controller.ts delete mode 100644 apps/web/src/timeline/drag-data.ts create mode 100644 apps/web/src/timeline/drag-source.ts diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/animation/timeline-snap-points.ts index f7bf549a..e64de11f 100644 --- a/apps/web/src/animation/timeline-snap-points.ts +++ b/apps/web/src/animation/timeline-snap-points.ts @@ -1,6 +1,7 @@ import { getElementKeyframes } from "@/animation"; import type { SceneTracks } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import { addMediaTime } from "@/wasm"; export function getAnimationKeyframeSnapPointsForTimeline({ tracks, @@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({ animations: element.animations, })) { snapPoints.push({ - time: element.startTime + keyframe.time, + time: addMediaTime({ a: element.startTime, b: keyframe.time }), type: "keyframe", elementId: element.id, trackId: track.id, diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index a7085154..94e3f3e1 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -1,268 +1,268 @@ -"use client"; - -import type { CSSProperties } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { Check, Copy, Download } from "lucide-react"; -import { useState } from "react"; -import { BasePage } from "@/app/base-page"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; - -function downloadAsset(src: string) { - const filename = src.split("/").pop() ?? "asset.svg"; - const a = document.createElement("a"); - a.href = src; - a.download = filename; - a.click(); -} - -async function copyAsset(src: string) { - const res = await fetch(src); - const text = await res.text(); - await navigator.clipboard.writeText(text); -} - -const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); - -type AssetTheme = "dark" | "light" | "icon"; - -interface AssetVariant { - src: string; - theme: AssetTheme; - label: string; - width: number; - height: number; -} - -interface AssetSection { - title: string; - description: string; - cols: "1" | "2"; - assets: AssetVariant[]; -} - -const ASSET_SECTIONS: AssetSection[] = [ - { - title: "Symbol", - description: - "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", - cols: "2", - assets: [ - { - src: "/logos/opencut/symbol.svg", - theme: "dark", - label: "Symbol", - width: 400, - height: 400, - }, - { - src: "/logos/opencut/symbol-light.svg", - theme: "light", - label: "Symbol", - width: 400, - height: 400, - }, - ], - }, - { - title: "Lockup", - description: - "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", - cols: "2", - assets: [ - { - src: "/logos/opencut/logo.svg", - theme: "dark", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/logo-light.svg", - theme: "light", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/text.svg", - theme: "dark", - label: "Text", - width: 1760, - height: 400, - }, - { - src: "/logos/opencut/text-light.svg", - theme: "light", - label: "Text", - width: 1760, - height: 400, - }, - ], - }, -]; - -export default function BrandPage() { - return ( - - Download OpenCut brand assets for use in your projects.{" "} - - document - .getElementById("guidelines") - ?.scrollIntoView({ behavior: "smooth" }) - } - > - Read the brand guidelines. - - - } - action={ - - } - > -
- {ASSET_SECTIONS.map((section) => ( -
-
-

{section.title}

-

- {section.description} -

-
-
- {section.assets.map((variant) => ( - - ))} -
-
- ))} -
- - - -
-
-

Usage

-

- OpenCut is open source — the code is free to use under its license. - That license does not cover the name or logo. You can say you use - OpenCut, that your project integrates with OpenCut, or that it was - built on top of OpenCut. You cannot name your product OpenCut, imply - we made or endorse your product, or use the marks commercially - without asking first. For anything unclear, reach out at{" "} - - brand@opencut.app - - . -

-
- -
-

What's not allowed

-
    - {[ - "Using OpenCut in the name of your product, service, or domain.", - "Implying that OpenCut made, sponsors, or endorses your work.", - "Using the logo or name on merchandise or commercial marketing.", - "Modifying the marks.", - ].map((item) => ( -
  • - - - {item} -
  • - ))} -
-
-
-
- ); -} - -const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { - light: { - backgroundImage: - "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#000", - }, - dark: { - backgroundImage: - "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#f5f5f5", - }, -}; - -function AssetCard({ variant }: { variant: AssetVariant }) { - const [copied, setCopied] = useState(false); - - async function handleCopy() { - await copyAsset(variant.src); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - return ( - -
- {variant.label} -
- - -
- ); -} +"use client"; + +import type { CSSProperties } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Check, Copy, Download } from "lucide-react"; +import { useState } from "react"; +import { BasePage } from "@/app/base-page"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; + +function downloadAsset(src: string) { + const filename = src.split("/").pop() ?? "asset.svg"; + const a = document.createElement("a"); + a.href = src; + a.download = filename; + a.click(); +} + +async function copyAsset(src: string) { + const res = await fetch(src); + const text = await res.text(); + await navigator.clipboard.writeText(text); +} + +const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); + +type AssetTheme = "dark" | "light" | "icon"; + +interface AssetVariant { + src: string; + theme: AssetTheme; + label: string; + width: number; + height: number; +} + +interface AssetSection { + title: string; + description: string; + cols: "1" | "2"; + assets: AssetVariant[]; +} + +const ASSET_SECTIONS: AssetSection[] = [ + { + title: "Symbol", + description: + "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", + cols: "2", + assets: [ + { + src: "/logos/opencut/symbol.svg", + theme: "dark", + label: "Symbol", + width: 400, + height: 400, + }, + { + src: "/logos/opencut/symbol-light.svg", + theme: "light", + label: "Symbol", + width: 400, + height: 400, + }, + ], + }, + { + title: "Lockup", + description: + "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", + cols: "2", + assets: [ + { + src: "/logos/opencut/logo.svg", + theme: "dark", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/logo-light.svg", + theme: "light", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/text.svg", + theme: "dark", + label: "Text", + width: 1760, + height: 400, + }, + { + src: "/logos/opencut/text-light.svg", + theme: "light", + label: "Text", + width: 1760, + height: 400, + }, + ], + }, +]; + +export default function BrandPage() { + return ( + + Download OpenCut brand assets for use in your projects.{" "} + + document + .getElementById("guidelines") + ?.scrollIntoView({ behavior: "smooth" }) + } + > + Read the brand guidelines. + + + } + action={ + + } + > +
+ {ASSET_SECTIONS.map((section) => ( +
+
+

{section.title}

+

+ {section.description} +

+
+
+ {section.assets.map((variant) => ( + + ))} +
+
+ ))} +
+ + + +
+
+

Usage

+

+ OpenCut is open source — the code is free to use under its license. + That license does not cover the name or logo. You can say you use + OpenCut, that your project integrates with OpenCut, or that it was + built on top of OpenCut. You cannot name your product OpenCut, imply + we made or endorse your product, or use the marks commercially + without asking first. For anything unclear, reach out at{" "} + + brand@opencut.app + + . +

+
+ +
+

What's not allowed

+
    + {[ + "Using OpenCut in the name of your product, service, or domain.", + "Implying that OpenCut made, sponsors, or endorses your work.", + "Using the logo or name on merchandise or commercial marketing.", + "Modifying the marks.", + ].map((item) => ( +
  • + - + {item} +
  • + ))} +
+
+
+
+ ); +} + +const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { + light: { + backgroundImage: + "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#000", + }, + dark: { + backgroundImage: + "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#f5f5f5", + }, +}; + +function AssetCard({ variant }: { variant: AssetVariant }) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await copyAsset(variant.src); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + +
+ {variant.label} +
+ + +
+ ); +} diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 8450cec2..aaae79a9 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,9 +1,17 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; +import { + formatTimecode, + type FrameRate, + type TimeCodeFormat, +} from "opencut-wasm"; import { cn } from "@/utils/ui"; -import type { MediaTime } from "@/wasm"; +import { + parseMediaTimecode, + snapSeekMediaTime, + type MediaTime, +} from "@/wasm"; interface EditableTimecodeProps { time: MediaTime; @@ -47,19 +55,20 @@ export function EditableTimecode({ }; const applyEdit = () => { - const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps }); + const parsedTime = parseMediaTimecode({ + timeCode: inputValue, + format, + fps, + }); if (parsedTime == null) { setHasError(true); return; } - const clampedTime = ( - duration - ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? - parsedTime) - : parsedTime - ) as MediaTime; + const clampedTime = duration + ? snapSeekMediaTime({ time: parsedTime, duration, fps }) + : parsedTime; onTimeChange?.({ time: clampedTime }); setIsEditing(false); diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index fdbf5e76..8cfb99c3 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -11,7 +11,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useEditor } from "@/editor/use-editor"; -import { clearDragData, setDragData } from "@/timeline/drag-data"; import type { TimelineDragData } from "@/timeline/drag"; import { cn } from "@/utils/ui"; import type { MediaTime } from "@/wasm"; @@ -74,21 +73,23 @@ export function DraggableItem({ }; }, [isDragging]); - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setDragImage(emptyImg, 0, 0); + const handleDragStart = (event: React.DragEvent) => { + event.dataTransfer.setDragImage(emptyImg, 0, 0); - setDragData({ dataTransfer: e.dataTransfer, dragData }); - e.dataTransfer.effectAllowed = "copy"; + editor.timeline.dragSource.begin({ + dataTransfer: event.dataTransfer, + dragData, + }); - setDragPosition({ x: e.clientX, y: e.clientY }); + setDragPosition({ x: event.clientX, y: event.clientY }); setIsDragging(true); - onDragStart?.({ e }); + onDragStart?.({ e: event }); }; const handleDragEnd = () => { setIsDragging(false); - clearDragData(); + editor.timeline.dragSource.end(); }; return ( diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 545565be..d0716aad 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -1,9 +1,6 @@ import type { EditorCore } from "@/core"; import { TICKS_PER_SECOND } from "@/wasm"; -import { - clampRetimeRate, - shouldMaintainPitch, -} from "@/retime/rate"; +import { clampRetimeRate, shouldMaintainPitch } from "@/retime/rate"; import type { AudioClipSource } from "@/media/audio"; import { createAudioContext, collectAudioClips } from "@/media/audio"; import { @@ -56,10 +53,8 @@ export class AudioManager { this.editor.playback.subscribe(this.handlePlaybackChange), this.editor.timeline.subscribe(this.handleTimelineChange), this.editor.media.subscribe(this.handleTimelineChange), + this.editor.playback.onSeek(this.handleSeek), ); - if (typeof window !== "undefined") { - window.addEventListener("playback-seek", this.handleSeek); - } } dispose(): void { @@ -68,9 +63,6 @@ export class AudioManager { unsub(); } this.unsubscribers = []; - if (typeof window !== "undefined") { - window.removeEventListener("playback-seek", this.handleSeek); - } this.disposeSinks(); this.preparedClipBuffers.clear(); this.decodedBuffers.clear(); @@ -102,17 +94,14 @@ export class AudioManager { } }; - private handleSeek = (event: Event): void => { - const detail = (event as CustomEvent<{ time: number }>).detail; - if (!detail) return; - + private handleSeek = (time: number): void => { if (this.editor.playback.getIsScrubbing()) { this.stopPlayback(); return; } if (this.editor.playback.getIsPlaying()) { - void this.startPlayback({ time: detail.time / TICKS_PER_SECOND }); + void this.startPlayback({ time: time / TICKS_PER_SECOND }); return; } @@ -126,7 +115,9 @@ export class AudioManager { if (!this.editor.playback.getIsPlaying()) return; - void this.startPlayback({ time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND }); + void this.startPlayback({ + time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND, + }); }; private ensureAudioContext(): AudioContext | null { diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 6748fd42..8275344f 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -4,9 +4,9 @@ import { clampMediaTime, type MediaTime, mediaTimeFromSeconds, + roundFrameTime, ZERO_MEDIA_TIME, } from "@/wasm"; -import { roundToFrame } from "opencut-wasm"; export class PlaybackManager { private isPlaying = false; @@ -16,6 +16,8 @@ export class PlaybackManager { private previousVolume = 1; private isScrubbing = false; private listeners = new Set<() => void>(); + private updateListeners = new Set<(time: MediaTime) => void>(); + private seekListeners = new Set<(time: MediaTime) => void>(); private playbackTimer: number | null = null; private playbackStartWallTime = 0; private playbackStartTime: MediaTime = ZERO_MEDIA_TIME; @@ -139,6 +141,16 @@ export class PlaybackManager { return () => this.listeners.delete(listener); } + onUpdate(listener: (time: MediaTime) => void): () => void { + this.updateListeners.add(listener); + return () => this.updateListeners.delete(listener); + } + + onSeek(listener: (time: MediaTime) => void): () => void { + this.seekListeners.add(listener); + return () => this.seekListeners.delete(listener); + } + private reconcileTimelineScope(): void { const maxTime = this.editor.timeline.getTotalDuration(); const nextTime = this.clampTimeToTimeline(this.currentTime); @@ -158,6 +170,7 @@ export class PlaybackManager { this.notify(); if (timeChanged) { + this.notifySeek(this.currentTime); this.dispatchSeekEvent(this.currentTime); } } @@ -168,6 +181,18 @@ export class PlaybackManager { }); } + private notifyUpdate(time: MediaTime): void { + this.updateListeners.forEach((fn) => { + fn(time); + }); + } + + private notifySeek(time: MediaTime): void { + this.seekListeners.forEach((fn) => { + fn(time); + }); + } + private startTimer(): void { if (this.playbackTimer) { cancelAnimationFrame(this.playbackTimer); @@ -195,20 +220,20 @@ export class PlaybackManager { a: this.playbackStartTime, b: mediaTimeFromSeconds({ seconds: elapsedSeconds }), }); - const newTime = (fps - ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) - : rawTime) as MediaTime; + const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime; const maxTime = this.editor.timeline.getTotalDuration(); if (newTime >= maxTime) { this.pause(); this.currentTime = maxTime; this.notify(); + this.notifySeek(maxTime); this.dispatchSeekEvent(maxTime); return; } this.currentTime = newTime; + this.notifyUpdate(newTime); this.dispatchUpdateEvent(newTime); this.playbackTimer = requestAnimationFrame(this.updateTime); }; diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index a1ead6ca..3c6b7599 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -9,8 +9,9 @@ import type { RetimeConfig, } from "@/timeline"; import { calculateTotalDuration } from "@/timeline"; +import { TimelineDragSource } from "@/timeline/drag-source"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; -import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import { canElementBeHidden, canElementHaveAudio, @@ -27,7 +28,6 @@ import { resolveAnimationTarget, resolveAnimationPathValueAtTime, } from "@/animation"; -import { lastFrameTime } from "opencut-wasm"; import { BatchCommand } from "@/commands"; import { AddTrackCommand, @@ -68,6 +68,7 @@ export class TimelineManager { private listeners = new Set<() => void>(); private previewOverlay = new Map>(); private previewTracks: SceneTracks | null = null; + public readonly dragSource = new TimelineDragSource(); constructor(private editor: EditorCore) {} @@ -214,7 +215,7 @@ export class TimelineManager { const duration = this.getTotalDuration(); const fps = this.editor.project.getActive()?.settings.fps; if (!fps || duration <= 0) return duration; - return (lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime; + return lastFrameMediaTime({ duration, fps }); } getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { @@ -707,11 +708,11 @@ export class TimelineManager { previewElements({ updates, }: { - updates: Array<{ + updates: readonly { trackId: string; elementId: string; updates: Partial; - }>; + }[]; }): void { let changedOverlayCount = 0; for (const { elementId, updates: elementUpdates } of updates) { diff --git a/apps/web/src/media/use-file-upload.ts b/apps/web/src/media/use-file-upload.ts index 1ee30ad9..c8c62073 100644 --- a/apps/web/src/media/use-file-upload.ts +++ b/apps/web/src/media/use-file-upload.ts @@ -1,5 +1,5 @@ import { useState, useRef } from "react"; -import { hasDragData } from "@/timeline/drag-data"; +import { useEditor } from "@/editor/use-editor"; interface UseFileUploadOptions { accept?: string; @@ -7,19 +7,23 @@ interface UseFileUploadOptions { onFilesSelected?: (files: File[]) => void; } -function containsFiles(dataTransfer: DataTransfer): boolean { - return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files"); -} - export function useFileUpload({ accept, multiple, onFilesSelected, }: UseFileUploadOptions = {}) { + const editor = useEditor(); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const inputRef = useRef(null); + function containsFiles(dataTransfer: DataTransfer): boolean { + return ( + !editor.timeline.dragSource.isActive() && + dataTransfer.types.includes("Files") + ); + } + function openFilePicker() { if (!inputRef.current) return; diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index 084c5b55..857b43e2 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -73,15 +73,13 @@ function TimecodeDisplay() { ); useEffect(() => { - const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time); - window.addEventListener("playback-update", handler); - window.addEventListener("playback-seek", handler); + const unsubscribeUpdate = editor.playback.onUpdate(setCurrentTime); + const unsubscribeSeek = editor.playback.onSeek(setCurrentTime); return () => { - window.removeEventListener("playback-update", handler); - window.removeEventListener("playback-seek", handler); + unsubscribeUpdate(); + unsubscribeSeek(); }; - }, []); + }, [editor.playback]); return (
diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts new file mode 100644 index 00000000..00573662 --- /dev/null +++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts @@ -0,0 +1,583 @@ +import type { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, +} from "react"; +import type { MediaAsset } from "@/media/types"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/preview/element-bounds"; +import { + getHitElements, + hitTest, + resolvePreferredHit, +} from "@/preview/hit-test"; +import { + SNAP_THRESHOLD_SCREEN_PIXELS, + snapPosition, + type SnapLine, +} from "@/preview/preview-snap"; +import type { TCanvasSize } from "@/project/types"; +import type { Transform } from "@/rendering"; +import { isVisualElement } from "@/timeline/element-utils"; +import type { + ElementRef, + SceneTracks, + TextElement, + TimelineElement, + TimelineTrack, + VisualElement, +} from "@/timeline"; + +const MIN_DRAG_DISTANCE = 0.5; +const PRIMARY_POINTER_BUTTON = 0; + +type Point = { readonly x: number; readonly y: number }; + +interface CapturedPointerState { + readonly pointerId: number; + readonly captureTarget: HTMLElement; +} + +interface PendingGesture extends CapturedPointerState { + readonly kind: "pending"; + readonly origin: Point; + readonly topmostHit: ElementWithBounds | null; + readonly selectedHit: ElementWithBounds | null; + readonly selectedElements: readonly ElementRef[]; +} + +interface DragElementSnapshot { + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; +} + +interface DraggingGesture extends CapturedPointerState { + readonly kind: "dragging"; + readonly origin: Point; + readonly bounds: { + readonly width: number; + readonly height: number; + readonly rotation: number; + }; + readonly elements: readonly DragElementSnapshot[]; +} + +type GestureSession = + | { readonly kind: "idle" } + | PendingGesture + | DraggingGesture; + +const IDLE_GESTURE: GestureSession = { kind: "idle" }; + +export interface EditingTextState { + readonly trackId: string; + readonly elementId: string; + readonly element: TextElement; +} + +export interface PreviewViewportAdapter { + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => Point | null; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => Point; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getTracks: () => SceneTracks; + getCurrentTime: () => number; + getMediaAssets: () => MediaAsset[]; + getCanvasSize: () => TCanvasSize; +} + +export interface SelectionApi { + getSelected: () => readonly ElementRef[]; + setSelected: (elements: readonly ElementRef[]) => void; + clearSelection: () => void; +} + +export interface TimelinePreviewUpdate { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial; +} + +export interface TimelineOps { + getElementsWithTracks: ({ + elements, + }: { + elements: readonly ElementRef[]; + }) => Array<{ track: TimelineTrack; element: TimelineElement }>; + previewElements: (updates: readonly TimelinePreviewUpdate[]) => void; + commitPreview: () => void; + discardPreview: () => void; +} + +export interface PlaybackApi { + getIsPlaying: () => boolean; + subscribe: (listener: () => void) => () => void; +} + +export interface PreviewOptions { + isMaskMode: () => boolean; + onSnapLinesChange?: (lines: SnapLine[]) => void; +} + +export interface PreviewInteractionDeps { + viewport: PreviewViewportAdapter; + input: InputAdapter; + scene: SceneReader; + selection: SelectionApi; + timeline: TimelineOps; + playback: PlaybackApi; + preview: PreviewOptions; +} + +export interface PreviewInteractionDepsRef { + readonly current: PreviewInteractionDeps; +} + +function isSameElementRef({ + left, + right, +}: { + left: ElementRef; + right: ElementRef; +}): boolean { + return left.trackId === right.trackId && left.elementId === right.elementId; +} + +function buildDragSelection({ + selectedElements, + dragTarget, +}: { + selectedElements: readonly ElementRef[]; + dragTarget: ElementWithBounds; +}): ElementRef[] { + const dragTargetRef = { + trackId: dragTarget.trackId, + elementId: dragTarget.elementId, + }; + + if ( + !selectedElements.some((selectedElement) => + isSameElementRef({ left: selectedElement, right: dragTargetRef }), + ) + ) { + return [dragTargetRef]; + } + + return [ + dragTargetRef, + ...selectedElements.filter( + (selectedElement) => + !isSameElementRef({ left: selectedElement, right: dragTargetRef }), + ), + ]; +} + +function movedPastDragThreshold({ + current, + origin, +}: { + current: Point; + origin: Point; +}): boolean { + return ( + Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE || + Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE + ); +} + +function toDragElementSnapshots({ + elementsWithTracks, +}: { + elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>; +}): DragElementSnapshot[] { + const isVisualTrackedElement = (value: { + track: TimelineTrack; + element: TimelineElement; + }): value is { track: TimelineTrack; element: VisualElement } => + isVisualElement(value.element); + + return elementsWithTracks + .filter(isVisualTrackedElement) + .map(({ track, element }) => ({ + trackId: track.id, + elementId: element.id, + initialTransform: element.transform, + })); +} + +export class PreviewInteractionController { + private readonly depsRef: PreviewInteractionDepsRef; + private readonly subscribers = new Set<() => void>(); + + private gesture: GestureSession = IDLE_GESTURE; + private editingTextState: EditingTextState | null = null; + private wasPlaying: boolean; + private unsubscribePlayback: (() => void) | null = null; + + constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) { + this.depsRef = depsRef; + this.wasPlaying = this.deps.playback.getIsPlaying(); + + this.onDoubleClick = this.onDoubleClick.bind(this); + this.onPointerDown = this.onPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + this.commitTextEdit = this.commitTextEdit.bind(this); + this.handlePlaybackChange = this.handlePlaybackChange.bind(this); + + this.unsubscribePlayback = this.deps.playback.subscribe( + this.handlePlaybackChange, + ); + } + + private get deps(): PreviewInteractionDeps { + return this.depsRef.current; + } + + get isDragging(): boolean { + return this.gesture.kind === "dragging"; + } + + get editingText(): EditingTextState | null { + return this.editingTextState; + } + + subscribe({ listener }: { listener: () => void }): () => void { + this.subscribers.add(listener); + return () => this.subscribers.delete(listener); + } + + destroy(): void { + this.unsubscribePlayback?.(); + this.unsubscribePlayback = null; + this.abortActiveGesture(); + this.editingTextState = null; + this.subscribers.clear(); + } + + cancel(): void { + if (this.gesture.kind === "idle") return; + this.abortActiveGesture(); + this.notify(); + } + + private abortActiveGesture(): void { + if (this.gesture.kind === "idle") return; + + if (this.gesture.kind === "dragging") { + this.deps.timeline.discardPreview(); + } + + this.releaseCapturedPointer({ pointerState: this.gesture }); + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + } + + commitTextEdit(): void { + if (!this.editingTextState) return; + + this.editingTextState = null; + this.deps.timeline.commitPreview(); + this.notify(); + } + + onDoubleClick({ clientX, clientY }: ReactMouseEvent): void { + if (this.editingTextState || this.deps.preview.isMaskMode()) return; + + const startPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!startPos) return; + + const hit = hitTest({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + + if (!hit || hit.element.type !== "text") return; + + this.editingTextState = { + trackId: hit.trackId, + elementId: hit.elementId, + element: hit.element, + }; + this.notify(); + } + + onPointerDown({ + clientX, + clientY, + currentTarget, + pointerId, + button, + }: ReactPointerEvent): void { + if (this.editingTextState) return; + if (this.deps.preview.isMaskMode()) return; + if (button !== PRIMARY_POINTER_BUTTON) return; + + const startPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!startPos) return; + + const hits = getHitElements({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + const selectedElements = this.deps.selection.getSelected(); + + this.gesture = { + kind: "pending", + origin: startPos, + pointerId, + captureTarget: currentTarget as HTMLElement, + topmostHit: hits[0] ?? null, + selectedHit: resolvePreferredHit({ + hits, + preferredElements: [...selectedElements], + }), + selectedElements, + }; + + currentTarget.setPointerCapture(pointerId); + } + + onPointerMove({ clientX, clientY }: ReactPointerEvent): void { + const currentPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!currentPos) return; + + if (this.gesture.kind === "pending") { + const pending = this.gesture; + if ( + !movedPastDragThreshold({ + current: currentPos, + origin: pending.origin, + }) + ) { + this.clearSnapLines(); + return; + } + + this.beginDragFromPending({ pending }); + } + + if (this.gesture.kind !== "dragging") return; + + this.updateDragPreview({ + drag: this.gesture, + currentPos, + }); + } + + onPointerUp({ type }: ReactPointerEvent): void { + if (this.gesture.kind === "dragging") { + const drag = this.gesture; + + if (type === "pointercancel") { + this.deps.timeline.discardPreview(); + } else { + this.deps.timeline.commitPreview(); + } + + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: drag }); + this.notify(); + return; + } + + if (this.gesture.kind !== "pending") return; + + const pending = this.gesture; + + if (type !== "pointercancel") { + const clickTarget = pending.topmostHit; + if (!clickTarget) { + this.deps.selection.clearSelection(); + } else { + this.deps.selection.setSelected([ + { + trackId: clickTarget.trackId, + elementId: clickTarget.elementId, + }, + ]); + } + } + + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + } + + private notify(): void { + for (const listener of this.subscribers) listener(); + } + + private clearSnapLines(): void { + this.deps.preview.onSnapLinesChange?.([]); + } + + private releaseCapturedPointer({ + pointerState, + }: { + pointerState: CapturedPointerState | null; + }): void { + if (!pointerState) return; + + if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) { + return; + } + + pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + } + + private getVisibleElementsWithBounds(): ElementWithBounds[] { + return getVisibleElementsWithBounds({ + tracks: this.deps.scene.getTracks(), + currentTime: this.deps.scene.getCurrentTime(), + canvasSize: this.deps.scene.getCanvasSize(), + mediaAssets: this.deps.scene.getMediaAssets(), + }); + } + + private handlePlaybackChange(): void { + const isPlaying = this.deps.playback.getIsPlaying(); + if (isPlaying && !this.wasPlaying && this.editingTextState) { + this.commitTextEdit(); + } + this.wasPlaying = isPlaying; + } + + private beginDragFromPending({ pending }: { pending: PendingGesture }): void { + const dragTarget = pending.selectedHit ?? pending.topmostHit; + if (!dragTarget) { + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + return; + } + + const dragSelection = buildDragSelection({ + selectedElements: pending.selectedElements, + dragTarget, + }); + const draggableElements = toDragElementSnapshots({ + elementsWithTracks: this.deps.timeline.getElementsWithTracks({ + elements: dragSelection, + }), + }); + + if (draggableElements.length === 0) { + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + return; + } + + if (pending.selectedHit === null) { + this.deps.selection.setSelected([ + { + trackId: dragTarget.trackId, + elementId: dragTarget.elementId, + }, + ]); + } + + this.gesture = { + kind: "dragging", + origin: pending.origin, + pointerId: pending.pointerId, + captureTarget: pending.captureTarget, + bounds: { + width: dragTarget.bounds.width, + height: dragTarget.bounds.height, + rotation: dragTarget.bounds.rotation, + }, + elements: draggableElements, + }; + this.notify(); + } + + private updateDragPreview({ + drag, + currentPos, + }: { + drag: DraggingGesture; + currentPos: Point; + }): void { + const firstElement = drag.elements[0]; + if (!firstElement) return; + + const deltaX = currentPos.x - drag.origin.x; + const deltaY = currentPos.y - drag.origin.y; + + const proposedPosition = { + x: firstElement.initialTransform.position.x + deltaX, + y: firstElement.initialTransform.position.y + deltaY, + }; + + const shouldSnap = !this.deps.input.isShiftHeld(); + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedPosition, activeLines } = shouldSnap + ? snapPosition({ + proposedPosition, + canvasSize: this.deps.scene.getCanvasSize(), + elementSize: drag.bounds, + rotation: drag.bounds.rotation, + snapThreshold, + }) + : { + snappedPosition: proposedPosition, + activeLines: [] as SnapLine[], + }; + + this.deps.preview.onSnapLinesChange?.(activeLines); + + const deltaSnappedX = + snappedPosition.x - firstElement.initialTransform.position.x; + const deltaSnappedY = + snappedPosition.y - firstElement.initialTransform.position.y; + + this.deps.timeline.previewElements( + drag.elements.map(({ trackId, elementId, initialTransform }) => ({ + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + position: { + x: initialTransform.position.x + deltaSnappedX, + y: initialTransform.position.y + deltaSnappedY, + }, + }, + }, + })), + ); + } +} diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts new file mode 100644 index 00000000..e9d251e3 --- /dev/null +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -0,0 +1,753 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import type { MediaAsset } from "@/media/types"; +import { + getVisibleElementsWithBounds, + type Corner, + type Edge, + type ElementBounds, + type ElementWithBounds, +} from "@/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/preview/preview-snap"; +import { isVisualElement } from "@/timeline/element-utils"; +import { + getElementLocalTime, + hasKeyframesForPath, + resolveTransformAtTime, + setChannel, +} from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; +import type { Transform } from "@/rendering"; +import type { + ElementRef, + SceneTracks, + TimelineElement, + VisualElement, +} from "@/timeline"; + +type Point = { readonly x: number; readonly y: number }; +type CanvasSize = { readonly width: number; readonly height: number }; +type HandleType = Corner | Edge | "rotation"; + +interface CapturedPointerState { + readonly pointerId: number; + readonly captureTarget: HTMLElement; +} + +interface CornerScaleSession extends CapturedPointerState { + readonly kind: "corner-scale"; + readonly corner: Corner; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialDistance: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface EdgeScaleSession extends CapturedPointerState { + readonly kind: "edge-scale"; + readonly edge: Edge; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly rotationRad: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationSession extends CapturedPointerState { + readonly kind: "rotation"; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialAngle: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; +} + +type TransformSession = + | { readonly kind: "idle" } + | CornerScaleSession + | EdgeScaleSession + | RotationSession; + +const IDLE_SESSION: TransformSession = { kind: "idle" }; + +interface VisualSelectionContext { + readonly trackId: string; + readonly elementId: string; + readonly element: VisualElement; + readonly bounds: ElementBounds; + readonly resolvedTransform: Transform; +} + +export interface PreviewViewportAdapter { + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => Point | null; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => Point; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getSelectedElements: () => readonly ElementRef[]; + getTracks: () => SceneTracks; + getCurrentTime: () => number; + getMediaAssets: () => MediaAsset[]; + getCanvasSize: () => CanvasSize; +} + +export interface TimelinePreviewUpdate { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial; +} + +export interface TimelineOps { + previewElements: (updates: readonly TimelinePreviewUpdate[]) => void; + commitPreview: () => void; + discardPreview: () => void; +} + +export interface PreviewOptions { + onSnapLinesChange?: (lines: SnapLine[]) => void; +} + +export interface TransformHandleDeps { + viewport: PreviewViewportAdapter; + input: InputAdapter; + scene: SceneReader; + timeline: TimelineOps; + preview: PreviewOptions; +} + +export interface TransformHandleDepsRef { + readonly current: TransformHandleDeps; +} + +function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): number { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +function buildSelectedWithBounds({ + selectedElements, + elementsWithBounds, +}: { + selectedElements: readonly ElementRef[]; + elementsWithBounds: readonly ElementWithBounds[]; +}): ElementWithBounds | null { + if (selectedElements.length !== 1) return null; + + return ( + elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null + ); +} + +function buildCornerScaleAnimationReset({ + animations, +}: { + animations: ElementAnimations | undefined; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleX", + }) || + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleY", + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : animations, + }; +} + +function buildEdgeScaleAnimationReset({ + animations, + edge, +}: { + animations: ElementAnimations | undefined; + edge: Edge; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + + const shouldClearScaleAnimation = hasKeyframesForPath({ + animations, + propertyPath, + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations, + propertyPath, + channel: undefined, + }) + : animations, + }; +} + +export class TransformHandleController { + private readonly depsRef: TransformHandleDepsRef; + private readonly subscribers = new Set<() => void>(); + + private session: TransformSession = IDLE_SESSION; + + constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) { + this.depsRef = depsRef; + + this.onCornerPointerDown = this.onCornerPointerDown.bind(this); + this.onEdgePointerDown = this.onEdgePointerDown.bind(this); + this.onRotationPointerDown = this.onRotationPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + } + + private get deps(): TransformHandleDeps { + return this.depsRef.current; + } + + get selectedWithBounds(): ElementWithBounds | null { + return buildSelectedWithBounds({ + selectedElements: this.deps.scene.getSelectedElements(), + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + } + + get activeHandle(): HandleType | null { + switch (this.session.kind) { + case "corner-scale": + return this.session.corner; + case "edge-scale": + return this.session.edge; + case "rotation": + return "rotation"; + default: + return null; + } + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.session.kind !== "idle") { + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + } + + this.subscribers.clear(); + } + + cancel(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + onCornerPointerDown({ + event, + corner, + }: { + event: ReactPointerEvent; + corner: Corner; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildCornerScaleAnimationReset({ + animations: context.element.animations, + }); + + this.session = { + kind: "corner-scale", + corner, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialDistance: getCornerDistance({ + bounds: context.bounds, + corner, + }), + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onRotationPointerDown({ event }: { event: ReactPointerEvent }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + const deltaX = position.x - context.bounds.cx; + const deltaY = position.y - context.bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + this.session = { + kind: "rotation", + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialAngle, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onEdgePointerDown({ + event, + edge, + }: { + event: ReactPointerEvent; + edge: Edge; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildEdgeScaleAnimationReset({ + animations: context.element.animations, + edge, + }); + + this.session = { + kind: "edge-scale", + edge, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + rotationRad: (context.bounds.rotation * Math.PI) / 180, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onPointerMove({ event }: { event: ReactPointerEvent }): void { + if (this.session.kind === "idle") return; + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + switch (this.session.kind) { + case "corner-scale": + this.previewCornerScale({ + session: this.session, + position, + }); + return; + case "edge-scale": + this.previewEdgeScale({ + session: this.session, + position, + }); + return; + case "rotation": + this.previewRotation({ + session: this.session, + position, + }); + return; + default: + return; + } + } + + onPointerUp(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.commitPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private clearSnapLines(): void { + this.deps.preview.onSnapLinesChange?.([]); + } + + private capturePointer({ + target, + pointerId, + }: { + target: HTMLElement; + pointerId: number; + }): HTMLElement { + target.setPointerCapture(pointerId); + return target; + } + + private releaseCapturedPointer(pointerState: CapturedPointerState): void { + if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) { + return; + } + + pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + } + + private getVisibleElementsWithBounds(): ElementWithBounds[] { + return getVisibleElementsWithBounds({ + tracks: this.deps.scene.getTracks(), + currentTime: this.deps.scene.getCurrentTime(), + canvasSize: this.deps.scene.getCanvasSize(), + mediaAssets: this.deps.scene.getMediaAssets(), + }); + } + + private getSelectedVisualContext(): VisualSelectionContext | null { + const selectedWithBounds = this.selectedWithBounds; + if (!selectedWithBounds) return null; + if (!isVisualElement(selectedWithBounds.element)) return null; + + const localTime = getElementLocalTime({ + timelineTime: this.deps.scene.getCurrentTime(), + elementStartTime: selectedWithBounds.element.startTime, + elementDuration: selectedWithBounds.element.duration, + }); + + return { + trackId: selectedWithBounds.trackId, + elementId: selectedWithBounds.elementId, + element: selectedWithBounds.element, + bounds: selectedWithBounds.bounds, + resolvedTransform: resolveTransformAtTime({ + baseTransform: selectedWithBounds.element.transform, + animations: selectedWithBounds.element.animations, + localTime, + }), + }; + } + + private previewCornerScale({ + session, + position, + }: { + session: CornerScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / session.initialDistance; + + // Use actual element dimensions (base * current scale) so snapping is + // computed from the rendered geometry when scaleX != scaleY. + const effectiveWidth = session.baseWidth * session.initialTransform.scaleX; + const effectiveHeight = + session.baseHeight * session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale, activeLines } = this.deps.input.isShiftHeld() + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: session.initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + }); + + this.deps.preview.onSnapLinesChange?.(activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: clampScaleNonZero( + session.initialTransform.scaleX * snappedScale, + ), + scaleY: clampScaleNonZero( + session.initialTransform.scaleY * snappedScale, + ), + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewEdgeScale({ + session, + position, + }: { + session: EdgeScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const xProjection = + deltaX * Math.cos(session.rotationRad) + + deltaY * Math.sin(session.rotationRad); + const yProjection = + -deltaX * Math.sin(session.rotationRad) + + deltaY * Math.cos(session.rotationRad); + const projection = + session.edge === "right" + ? xProjection + : session.edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + session.edge === "right" || session.edge === "left" + ? session.baseWidth / 2 + : session.baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + session.edge === "right" || session.edge === "left" + ? proposedScale + : session.initialTransform.scaleX; + const proposedScaleY = + session.edge === "bottom" + ? proposedScale + : session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld() + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: session.initialTransform.position, + baseWidth: session.baseWidth, + baseHeight: session.baseHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + preferredEdges: getPreferredEdge({ edge: session.edge }), + }); + + const relevantSnap = + session.edge === "right" || session.edge === "left" ? xSnap : ySnap; + this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: + session.edge === "right" || session.edge === "left" + ? xSnap.snappedScale + : session.initialTransform.scaleX, + scaleY: + session.edge === "bottom" + ? ySnap.snappedScale + : session.initialTransform.scaleY, + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewRotation({ + session, + position, + }: { + session: RotationSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - session.initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + const newRotate = session.initialTransform.rotate + deltaAngle; + const { snappedRotation } = this.deps.input.isShiftHeld() + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + rotate: snappedRotation, + }, + }, + }, + ]); + } +} diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts index 78212e74..d093dcf2 100644 --- a/apps/web/src/preview/hooks/use-preview-interaction.ts +++ b/apps/web/src/preview/hooks/use-preview-interaction.ts @@ -1,97 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; -import type { Transform } from "@/rendering"; -import type { ElementRef, TextElement } from "@/timeline"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/preview/element-bounds"; -import { - getHitElements, - hitTest, - resolvePreferredHit, -} from "@/preview/hit-test"; -import { isVisualElement } from "@/timeline/element-utils"; -import { - SNAP_THRESHOLD_SCREEN_PIXELS, - snapPosition, - type SnapLine, -} from "@/preview/preview-snap"; +import type { SnapLine } from "@/preview/preview-snap"; import { registerCanceller } from "@/editor/cancel-interaction"; +import { + PreviewInteractionController, + type PreviewInteractionDeps, + type PreviewInteractionDepsRef, +} from "@/preview/controllers/preview-interaction-controller"; export type OnSnapLinesChange = (lines: SnapLine[]) => void; -const MIN_DRAG_DISTANCE = 0.5; - -interface CapturedPointerState { - pointerId: number; - captureTarget: HTMLElement; -} - -interface PendingGestureState extends CapturedPointerState { - startX: number; - startY: number; - topmostHit: ElementWithBounds | null; - selectedHit: ElementWithBounds | null; - selectedElements: ElementRef[]; -} - -interface DragState extends CapturedPointerState { - startX: number; - startY: number; - bounds: { - width: number; - height: number; - rotation: number; - }; - elements: Array<{ - trackId: string; - elementId: string; - initialTransform: Transform; - }>; -} - -function isSameElementRef({ - left, - right, -}: { - left: ElementRef; - right: ElementRef; -}): boolean { - return left.trackId === right.trackId && left.elementId === right.elementId; -} - -function buildDragSelection({ - selectedElements, - dragTarget, -}: { - selectedElements: ElementRef[]; - dragTarget: ElementWithBounds; -}): ElementRef[] { - const dragTargetRef = { - trackId: dragTarget.trackId, - elementId: dragTarget.elementId, - }; - - if ( - !selectedElements.some((selectedElement) => - isSameElementRef({ left: selectedElement, right: dragTargetRef }), - ) - ) { - return [dragTargetRef]; - } - - return [ - dragTargetRef, - ...selectedElements.filter( - (selectedElement) => - !isSameElementRef({ left: selectedElement, right: dragTargetRef }), - ), - ]; -} - export function usePreviewInteraction({ onSnapLinesChange, isMaskMode = false, @@ -102,355 +22,74 @@ export function usePreviewInteraction({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const viewport = usePreviewViewport(); - const [isDragging, setIsDragging] = useState(false); - const [editingText, setEditingText] = useState<{ - trackId: string; - elementId: string; - element: TextElement; - originalOpacity: number; - } | null>(null); - const dragStateRef = useRef(null); - const pendingGestureRef = useRef(null); - const wasPlayingRef = useRef(editor.playback.getIsPlaying()); - const editingTextRef = useRef(editingText); - editingTextRef.current = editingText; - - const releaseCapturedPointer = useCallback( - (pointerState: CapturedPointerState | null) => { - if (!pointerState) return; - - if ( - !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId) - ) { - return; - } - - pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + const deps: PreviewInteractionDeps = { + viewport: { + screenToCanvas: viewport.screenToCanvas, + screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold, }, - [], - ); + input: { + isShiftHeld: () => isShiftHeldRef.current, + }, + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + getCanvasSize: () => editor.project.getActive().settings.canvasSize, + }, + selection: { + getSelected: () => editor.selection.getSelectedElements(), + setSelected: (elements) => + editor.selection.setSelectedElements({ elements: [...elements] }), + clearSelection: () => editor.selection.clearSelection(), + }, + timeline: { + getElementsWithTracks: ({ elements }) => + editor.timeline.getElementsWithTracks({ elements: [...elements] }), + previewElements: (updates) => + editor.timeline.previewElements({ updates: [...updates] }), + commitPreview: () => editor.timeline.commitPreview(), + discardPreview: () => editor.timeline.discardPreview(), + }, + playback: { + getIsPlaying: () => editor.playback.getIsPlaying(), + subscribe: (listener) => editor.playback.subscribe(listener), + }, + preview: { + isMaskMode: () => isMaskMode, + onSnapLinesChange, + }, + }; - const commitTextEdit = useCallback(() => { - const current = editingTextRef.current; - if (!current) return; - editingTextRef.current = null; - editor.timeline.commitPreview(); - setEditingText(null); - }, [editor.timeline]); + const depsRef = useRef(deps); + depsRef.current = deps; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new PreviewInteractionController({ + depsRef: depsRef as PreviewInteractionDepsRef, + }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect( + () => controller.subscribe({ listener: rerender }), + [controller], + ); useEffect(() => { - const unsubscribe = editor.playback.subscribe(() => { - const isPlaying = editor.playback.getIsPlaying(); - if (isPlaying && !wasPlayingRef.current && editingTextRef.current) { - commitTextEdit(); - } - wasPlayingRef.current = isPlaying; - }); - return unsubscribe; - }, [editor.playback, commitTextEdit]); + if (!controller.isDragging) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isDragging, controller]); - useEffect(() => { - if (!isDragging) return; - - return registerCanceller({ - fn: () => { - const dragState = dragStateRef.current; - if (!dragState) return; - - editor.timeline.discardPreview(); - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - }, - }); - }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]); - - const handleDoubleClick = useCallback( - ({ clientX, clientY }: React.MouseEvent) => { - if (editingText || isMaskMode) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hit = hitTest({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - - if (!hit || hit.element.type !== "text") return; - - const textElement = hit.element as TextElement; - setEditingText({ - trackId: hit.trackId, - elementId: hit.elementId, - element: textElement, - originalOpacity: textElement.opacity, - }); - }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerDown = useCallback( - ({ - clientX, - clientY, - currentTarget, - pointerId, - button, - }: React.PointerEvent) => { - if (editingText) return; - if (isMaskMode) return; - if (button !== 0) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hits = getHitElements({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - const selectedElements = editor.selection.getSelectedElements(); - const topmostHit = hits[0] ?? null; - - pendingGestureRef.current = { - startX: startPos.x, - startY: startPos.y, - pointerId, - captureTarget: currentTarget as HTMLElement, - topmostHit, - selectedHit: resolvePreferredHit({ - hits, - preferredElements: selectedElements, - }), - selectedElements, - }; - currentTarget.setPointerCapture(pointerId); - }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerMove = useCallback( - ({ clientX, clientY }: React.PointerEvent) => { - const canvasSize = editor.project.getActive().settings.canvasSize; - - const currentPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!currentPos) return; - - let dragState = dragStateRef.current; - - if (!dragState) { - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; - - const deltaX = currentPos.x - pendingGesture.startX; - const deltaY = currentPos.y - pendingGesture.startY; - const hasMovement = - Math.abs(deltaX) > MIN_DRAG_DISTANCE || - Math.abs(deltaY) > MIN_DRAG_DISTANCE; - - if (!hasMovement) { - onSnapLinesChange?.([]); - return; - } - - const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit; - if (!dragTarget) { - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - return; - } - - const dragSelection = buildDragSelection({ - selectedElements: pendingGesture.selectedElements, - dragTarget, - }); - const elementsWithTracks = editor.timeline.getElementsWithTracks({ - elements: dragSelection, - }); - const draggableElements = elementsWithTracks.filter(({ element }) => - isVisualElement(element), - ); - - if (draggableElements.length === 0) { - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - return; - } - - if (pendingGesture.selectedHit === null) { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: dragTarget.trackId, - elementId: dragTarget.elementId, - }, - ], - }); - } - - dragState = { - startX: pendingGesture.startX, - startY: pendingGesture.startY, - pointerId: pendingGesture.pointerId, - captureTarget: pendingGesture.captureTarget, - bounds: { - width: dragTarget.bounds.width, - height: dragTarget.bounds.height, - rotation: dragTarget.bounds.rotation, - }, - elements: draggableElements.map(({ track, element }) => ({ - trackId: track.id, - elementId: element.id, - initialTransform: (element as { transform: Transform }).transform, - })), - }; - dragStateRef.current = dragState; - pendingGestureRef.current = null; - setIsDragging(true); - } - - const deltaX = currentPos.x - dragState.startX; - const deltaY = currentPos.y - dragState.startY; - const firstElement = dragState.elements[0]; - const proposedPosition = { - x: firstElement.initialTransform.position.x + deltaX, - y: firstElement.initialTransform.position.y + deltaY, - }; - - const shouldSnap = !isShiftHeldRef.current; - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedPosition, activeLines } = shouldSnap - ? snapPosition({ - proposedPosition, - canvasSize, - elementSize: dragState.bounds, - rotation: dragState.bounds.rotation, - snapThreshold, - }) - : { - snappedPosition: proposedPosition, - activeLines: [] as SnapLine[], - }; - - onSnapLinesChange?.(activeLines); - - const deltaSnappedX = - snappedPosition.x - firstElement.initialTransform.position.x; - const deltaSnappedY = - snappedPosition.y - firstElement.initialTransform.position.y; - - const updates = dragState.elements.map( - ({ trackId, elementId, initialTransform }) => ({ - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: { - x: initialTransform.position.x + deltaSnappedX, - y: initialTransform.position.y + deltaSnappedY, - }, - }, - }, - }), - ); - - editor.timeline.previewElements({ updates }); - }, - [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport], - ); - - const handlePointerUp = useCallback( - ({ type }: React.PointerEvent) => { - const dragState = dragStateRef.current; - if (dragState) { - if (type === "pointercancel") { - editor.timeline.discardPreview(); - } else { - editor.timeline.commitPreview(); - } - - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - return; - } - - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; - - if (type !== "pointercancel") { - const clickTarget = pendingGesture.topmostHit; - if (!clickTarget) { - editor.selection.clearSelection(); - } else { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: clickTarget.trackId, - elementId: clickTarget.elementId, - }, - ], - }); - } - } - - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - }, - [editor, onSnapLinesChange, releaseCapturedPointer], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - onPointerDown: handlePointerDown, - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - onDoubleClick: handleDoubleClick, - editingText, - commitTextEdit, + onPointerDown: controller.onPointerDown, + onPointerMove: controller.onPointerMove, + onPointerUp: controller.onPointerUp, + onDoubleClick: controller.onDoubleClick, + editingText: controller.editingText, + commitTextEdit: controller.commitTextEdit, }; } diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts index 030e026c..f4b7e306 100644 --- a/apps/web/src/preview/hooks/use-transform-handles.ts +++ b/apps/web/src/preview/hooks/use-transform-handles.ts @@ -1,629 +1,84 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/preview/element-bounds"; -import { - MIN_SCALE, - SNAP_THRESHOLD_SCREEN_PIXELS, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/preview/preview-snap"; -import { isVisualElement } from "@/timeline/element-utils"; -import { - getElementLocalTime, - hasKeyframesForPath, - resolveTransformAtTime, - setChannel, -} from "@/animation"; -import type { Transform } from "@/rendering"; -import type { ElementAnimations } from "@/animation/types"; import { registerCanceller } from "@/editor/cancel-interaction"; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -type Edge = "right" | "left" | "bottom"; -type HandleType = Corner | Edge | "rotation"; - -function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference { - return edge === "right" - ? { right: true } - : edge === "left" - ? { left: true } - : { bottom: true }; -} - -interface ScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialDistance: number; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -interface RotationState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialAngle: number; - initialBoundsCx: number; - initialBoundsCy: number; -} - -interface EdgeScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - edge: Edge; - rotationRad: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -function clampScaleNonZero(scale: number): number { - if (Math.abs(scale) < MIN_SCALE) { - return scale < 0 ? -MIN_SCALE : MIN_SCALE; - } - return scale; -} - -function getCornerDistance({ - bounds, - corner, -}: { - bounds: { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; - }; - corner: Corner; -}): number { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; - const localY = - corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; - - const rotatedX = localX * cos - localY * sin; - const rotatedY = localX * sin + localY * cos; - return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; -} +import { + TransformHandleController, + type TransformHandleDeps, +} from "@/preview/controllers/transform-handle-controller"; export function useTransformHandles({ onSnapLinesChange, }: { onSnapLinesChange?: OnSnapLinesChange; }) { + const viewport = usePreviewViewport(); const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandle, setActiveHandle] = useState(null); - const scaleStateRef = useRef(null); - const rotationStateRef = useRef(null); - const edgeScaleStateRef = useRef(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( (e) => e.project.getActive().settings.canvasSize, ); + const deps: TransformHandleDeps = { + viewport, + input: { + isShiftHeld: () => isShiftHeldRef.current, + }, + scene: { + getSelectedElements: () => selectedElements, + getTracks: () => tracks, + getCurrentTime: () => currentTime, + getMediaAssets: () => mediaAssets, + getCanvasSize: () => canvasSize, + }, + timeline: { + previewElements: (updates) => + editor.timeline.previewElements({ updates }), + commitPreview: () => editor.timeline.commitPreview(), + discardPreview: () => editor.timeline.discardPreview(), + }, + preview: { + onSnapLinesChange, + }, + }; - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); + const depsRef = useRef(deps); + depsRef.current = deps; - const selectedWithBounds: ElementWithBounds | null = - selectedElements.length === 1 - ? (elementsWithBounds.find( - (entry) => - entry.trackId === selectedElements[0].trackId && - entry.elementId === selectedElements[0].elementId, - ) ?? null) - : null; + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new TransformHandleController({ depsRef }); + } + const controller = controllerRef.current; - const hasVisualSelection = - selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); - - const clearActiveHandleState = useCallback(() => { - scaleStateRef.current = null; - rotationStateRef.current = null; - edgeScaleStateRef.current = null; - setActiveHandle(null); - onSnapLinesChange?.([]); - }, [onSnapLinesChange]); - - const releaseCapturedPointer = useCallback(() => { - const capture = captureRef.current; - if (!capture) return; - - if (capture.element.hasPointerCapture(capture.pointerId)) { - capture.element.releasePointerCapture(capture.pointerId); - } - - captureRef.current = null; - }, []); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!activeHandle) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller, controller.isActive]); - return registerCanceller({ - fn: () => { - editor.timeline.discardPreview(); - clearActiveHandleState(); - releaseCapturedPointer(); - }, - }); - }, [ - activeHandle, - clearActiveHandleState, - editor.timeline, - releaseCapturedPointer, - ]); + useEffect(() => () => controller.destroy(), [controller]); - const handleCornerPointerDown = useCallback( - ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const shouldClearScaleAnimation = - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleX", - }) || - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleY", - }); - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: setChannel({ - animations: element.animations, - propertyPath: "transform.scaleX", - channel: undefined, - }), - propertyPath: "transform.scaleY", - channel: undefined, - }) - : element.animations; - - scaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialDistance, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(corner); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handleRotationPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - const deltaX = position.x - bounds.cx; - const deltaY = position.y - bounds.cy; - const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - - rotationStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialAngle, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - }; - setActiveHandle("rotation"); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds, viewport], - ); - - const handleEdgePointerDown = useCallback( - ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const rotationRad = (bounds.rotation * Math.PI) / 180; - - const propertyPath = - edge === "right" || edge === "left" - ? "transform.scaleX" - : "transform.scaleY"; - const shouldClearScaleAnimation = hasKeyframesForPath({ - animations: element.animations, - propertyPath, - }); - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: element.animations, - propertyPath, - channel: undefined, - }) - : element.animations; - - edgeScaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(edge); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if ( - !scaleStateRef.current && - !rotationStateRef.current && - !edgeScaleStateRef.current - ) - return; - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - - if ( - scaleStateRef.current && - activeHandle && - activeHandle !== "rotation" - ) { - const { - trackId, - elementId, - initialTransform, - initialDistance, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - } = scaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = - Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; - const scaleFactor = currentDistance / initialDistance; - - // Use actual element dimensions (base * current scale) so snap - // computes the correct edges when scaleX ≠ scaleY - const effectiveWidth = baseWidth * initialTransform.scaleX; - const effectiveHeight = baseHeight * initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedScale: snappedFactor, activeLines } = - isShiftHeldRef.current - ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } - : snapScale({ - proposedScale: scaleFactor, - position: initialTransform.position, - baseWidth: effectiveWidth, - baseHeight: effectiveHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: clampScaleNonZero( - initialTransform.scaleX * snappedFactor, - ), - scaleY: clampScaleNonZero( - initialTransform.scaleY * snappedFactor, - ), - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if ( - edgeScaleStateRef.current && - (activeHandle === "right" || - activeHandle === "left" || - activeHandle === "bottom") - ) { - const { - trackId, - elementId, - initialTransform, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - } = edgeScaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const xProjection = - deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); - const yProjection = - -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); - const projection = - edge === "right" - ? xProjection - : edge === "left" - ? -xProjection - : yProjection; - - const baseAxisHalf = - edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; - const proposedScale = clampScaleNonZero(projection / baseAxisHalf); - - const proposedScaleX = - edge === "right" || edge === "left" - ? proposedScale - : initialTransform.scaleX; - const proposedScaleY = - edge === "bottom" ? proposedScale : initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { x: xSnap, y: ySnap } = isShiftHeldRef.current - ? { - x: { - snappedScale: proposedScaleX, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - y: { - snappedScale: proposedScaleY, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - } - : snapScaleAxes({ - proposedScaleX, - proposedScaleY, - position: initialTransform.position, - baseWidth, - baseHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - preferredEdges: getPreferredEdge({ edge }), - }); - - const relevantSnap = - edge === "right" || edge === "left" ? xSnap : ySnap; - onSnapLinesChange?.(relevantSnap.activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: - edge === "right" || edge === "left" - ? xSnap.snappedScale - : initialTransform.scaleX, - scaleY: - edge === "bottom" - ? ySnap.snappedScale - : initialTransform.scaleY, - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if (rotationStateRef.current && activeHandle === "rotation") { - const { - trackId, - elementId, - initialTransform, - initialAngle, - initialBoundsCx, - initialBoundsCy, - } = rotationStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - let deltaAngle = currentAngle - initialAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - const newRotate = initialTransform.rotate + deltaAngle; - const { snappedRotation } = isShiftHeldRef.current - ? { snappedRotation: newRotate } - : snapRotation({ proposedRotation: newRotate }); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { ...initialTransform, rotate: snappedRotation }, - }, - }, - ], - }); - } - }, - [ - activeHandle, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if ( - scaleStateRef.current || - rotationStateRef.current || - edgeScaleStateRef.current - ) { - editor.timeline.commitPreview(); - clearActiveHandleState(); - } - releaseCapturedPointer(); - }, [clearActiveHandleState, editor, releaseCapturedPointer]); + const selectedWithBounds = controller.selectedWithBounds; + const hasVisualSelection = selectedWithBounds !== null; return { selectedWithBounds, hasVisualSelection, - activeHandle, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, + activeHandle: controller.activeHandle, + handleCornerPointerDown: controller.onCornerPointerDown, + handleEdgePointerDown: controller.onEdgePointerDown, + handleRotationPointerDown: controller.onRotationPointerDown, + handlePointerMove: controller.onPointerMove, + handlePointerUp: controller.onPointerUp, }; } diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts index e5f3991d..cad4fc31 100644 --- a/apps/web/src/services/video-cache/service.ts +++ b/apps/web/src/services/video-cache/service.ts @@ -7,6 +7,7 @@ import { } from "mediabunny"; interface VideoSinkData { + input: Input; sink: CanvasSink; iterator: AsyncGenerator | null; currentFrame: WrappedCanvas | null; @@ -20,6 +21,7 @@ export class VideoCache { private sinks = new Map(); private initPromises = new Map>(); private frameChain = new Map>(); + private seekGenerations = new Map(); async getFrameAt({ mediaId, @@ -35,11 +37,20 @@ export class VideoCache { const sinkData = this.sinks.get(mediaId); if (!sinkData) return null; + const generation = (this.seekGenerations.get(mediaId) ?? 0) + 1; + this.seekGenerations.set(mediaId, generation); + const previous = this.frameChain.get(mediaId) ?? Promise.resolve(); - const current = previous.then(() => - this.resolveFrame({ sinkData, time }), + const current = previous.then(() => { + if (this.seekGenerations.get(mediaId) !== generation) { + return sinkData.currentFrame ?? null; + } + return this.resolveFrame({ sinkData, time }); + }); + this.frameChain.set( + mediaId, + current.catch(() => {}), ); - this.frameChain.set(mediaId, current.catch(() => {})); return current; } @@ -172,18 +183,7 @@ export class VideoCache { if (frame) { sinkData.currentFrame = frame; - - // Aggressively fetch next frame immediately to fill buffer - // This matches the mediaplayer example which fetches 2 frames on start - try { - const { value: next } = await sinkData.iterator.next(); - if (next) { - sinkData.nextFrame = next; - } - } catch (e) { - console.warn("Failed to pre-fetch next frame on seek:", e); - } - + this.startPrefetch({ sinkData }); return frame; } } catch (error) { @@ -262,12 +262,12 @@ export class VideoCache { mediaId: string; file: File; }): Promise { - try { - const input = new Input({ - source: new BlobSource(file), - formats: ALL_FORMATS, - }); + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, + }); + try { const videoTrack = await input.getPrimaryVideoTrack(); if (!videoTrack) { throw new Error("No video track found"); @@ -284,6 +284,7 @@ export class VideoCache { }); this.sinks.set(mediaId, { + input, sink, iterator: null, currentFrame: null, @@ -293,6 +294,7 @@ export class VideoCache { prefetchPromise: null, }); } catch (error) { + input.dispose(); console.error(`Failed to initialize video sink for ${mediaId}:`, error); throw error; } @@ -305,10 +307,13 @@ export class VideoCache { void sinkData.iterator.return(); } + sinkData.input.dispose(); this.sinks.delete(mediaId); } this.initPromises.delete(mediaId); + this.frameChain.delete(mediaId); + this.seekGenerations.delete(mediaId); } clearAll(): void { diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx index 0f0710cb..43ba4ffc 100644 --- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag"; import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme"; import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout"; import { DEFAULT_FPS } from "@/fps/defaults"; -import { snappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, Delete02Icon, @@ -30,6 +29,7 @@ import { type MediaTime, mediaTimeFromSeconds, mediaTimeToSeconds, + snapSeekMediaTime, subMediaTime, ZERO_MEDIA_TIME, } from "@/wasm"; @@ -51,7 +51,7 @@ function seekToBookmarkTime({ const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const rate = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime; + const snappedTime = snapSeekMediaTime({ time, duration, fps: rate }); editor.playback.seek({ time: snappedTime }); } diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 8996330b..343fda80 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -8,7 +8,6 @@ import { import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame } from "opencut-wasm"; import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; import { buildTimelineSnapPoints, @@ -21,7 +20,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; import type { Bookmark } from "@/timeline"; -import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export interface BookmarkDragState { isDragging: boolean; @@ -117,7 +116,7 @@ export function useBookmarkDrag({ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), }); return { - snappedTime: result.snappedTime as MediaTime, + snappedTime: result.snappedTime, snapPoint: result.snapPoint, }; }, @@ -165,12 +164,10 @@ export function useBookmarkDrag({ }); const clampedTime = mouseTime > duration ? duration : mouseTime; - const frameSnappedTime = ( - roundToFrame({ - time: clampedTime, - rate: activeProject.settings.fps, - }) ?? clampedTime - ) as MediaTime; + const frameSnappedTime = roundFrameTime({ + time: clampedTime, + fps: activeProject.settings.fps, + }); const { snappedTime: initialTime } = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: bookmarkTime, @@ -199,10 +196,10 @@ export function useBookmarkDrag({ }); const clampedTime = mouseTime > duration ? duration : mouseTime; - const frameSnappedTime = ( - roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? - clampedTime - ) as MediaTime; + const frameSnappedTime = roundFrameTime({ + time: clampedTime, + fps: activeProject.settings.fps, + }); const snapResult = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: dragState.bookmarkTime, diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts index 32a84baa..64d0c3c3 100644 --- a/apps/web/src/timeline/bookmarks/snap-source.ts +++ b/apps/web/src/timeline/bookmarks/snap-source.ts @@ -1,12 +1,13 @@ import type { Bookmark } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import type { MediaTime } from "@/wasm"; export function getBookmarkSnapPoints({ bookmarks, excludeBookmarkTime, }: { bookmarks: Bookmark[]; - excludeBookmarkTime?: number; + excludeBookmarkTime?: MediaTime; }): SnapPoint[] { return bookmarks.flatMap((bookmark) => { if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) { diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts index 52e5aa60..702e66c3 100644 --- a/apps/web/src/timeline/bookmarks/utils.ts +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -1,7 +1,6 @@ import type { Bookmark } from "@/timeline"; import type { FrameRate } from "opencut-wasm"; -import { roundToFrame } from "opencut-wasm"; -import { addMediaTime, type MediaTime } from "@/wasm"; +import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm"; function bookmarkTimeEqual({ bookmarkTime, @@ -114,7 +113,7 @@ export function getFrameTime({ time: MediaTime; fps: FrameRate; }): MediaTime { - return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime; + return roundFrameTime({ time, fps }); } export function getBookmarkAtTime({ diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index 5d61a0a9..68f3f37f 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -30,7 +30,7 @@ import { type ReactNode, } from "react"; import type { MediaTime } from "@/wasm"; -import type { ElementDragState, DropTarget } from "@/timeline"; +import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "@/selection/selection-box"; @@ -288,20 +288,15 @@ export function Timeline() { isReady: tracks.length > 0, }); - const { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, - } = useElementInteraction({ + const { dragView, handleElementMouseDown, handleElementClick } = + useElementInteraction({ zoomLevel, - timelineRef, tracksContainerRef, tracksScrollRef, snappingEnabled, onSnapPointChange: handleSnapPointChange, }); + const isElementDragging = dragView.kind === "dragging"; const { dragState: bookmarkDragState, @@ -392,10 +387,19 @@ export function Timeline() { contentWidth: dynamicTimelineWidth, }); + useEdgeAutoScroll({ + isActive: isElementDragging, + getMouseClientX: () => + dragView.kind === "dragging" ? dragView.currentMouseX : 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + const showSnapIndicator = snappingEnabled && currentSnapPoint !== null && - (dragState.isDragging || bookmarkDragState.isDragging || isResizing); + (isElementDragging || bookmarkDragState.isDragging || isResizing); const { handleTracksMouseDown, @@ -458,9 +462,9 @@ export function Timeline() { headerHeight={timelineHeaderHeight} /> @@ -541,9 +545,7 @@ export function Timeline() { ; - lastMouseXRef: React.RefObject; + dragView: ElementDragView; onResizeStart: React.ComponentProps< typeof TimelineTrackContent >["onResizeStart"]; @@ -777,8 +775,16 @@ function TimelineTrackRows({ [tracks, expandedElementIds], ); + const draggingElementIds = useMemo( + () => + dragView.kind === "dragging" + ? dragView.memberTimeOffsets + : (null as ReadonlyMap | null), + [dragView], + ); const sortedTracks = useMemo(() => { - const draggingElementIds = new Set(dragState.dragElementIds); + if (!draggingElementIds) + return tracks.map((track, index) => ({ track, index })); return [...tracks] .map((track, index) => ({ track, index })) .sort((a, b) => { @@ -792,7 +798,7 @@ function TimelineTrackRows({ if (bHasDragged) return -1; return 0; }); - }, [tracks, dragState.dragElementIds]); + }, [tracks, draggingElementIds]); return ( <> @@ -812,10 +818,7 @@ function TimelineTrackRows({ void; - dragState: ElementDragState; + dragView: ElementDragView; isDropTarget?: boolean; } @@ -231,7 +226,7 @@ export function TimelineElement({ onResizeStart, onElementMouseDown, onElementClick, - dragState, + dragView, isDropTarget = false, }: TimelineElementProps) { const mediaAssets = useEditor((e) => e.media.getAssets()); @@ -257,16 +252,18 @@ export function TimelineElement({ selected.elementId === element.id && selected.trackId === track.id, ); - const isBeingDragged = dragState.dragElementIds.includes(element.id); + const isDragging = dragView.kind === "dragging"; + const dragTimeOffset = isDragging + ? dragView.memberTimeOffsets.get(element.id) + : undefined; + const isBeingDragged = dragTimeOffset !== undefined; const dragOffsetY = - isBeingDragged && dragState.isDragging - ? dragState.currentMouseY - dragState.startMouseY + isDragging && isBeingDragged + ? dragView.currentMouseY - dragView.startMouseY : 0; - const dragTimeOffset = - dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME; const elementStartTime = - isBeingDragged && dragState.isDragging - ? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset }) + isDragging && isBeingDragged + ? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset }) : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; @@ -388,7 +385,7 @@ export function TimelineElement({ ? `${baseTrackHeight + expansionHeight}px` : "100%", transform: - isBeingDragged && dragState.isDragging + isDragging && isBeingDragged ? `translate3d(0, ${dragOffsetY}px, 0)` : undefined, }} diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx index 6c40637e..7608e6df 100644 --- a/apps/web/src/timeline/components/timeline-track.tsx +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -5,18 +5,12 @@ import { TimelineElement } from "./timeline-element"; import type { TimelineTrack } from "@/timeline"; import type { TimelineElement as TimelineElementType } from "@/timeline"; import { TIMELINE_LAYERS } from "./layers"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; -import type { ElementDragState } from "@/timeline"; -import { useEditor } from "@/editor/use-editor"; +import type { ElementDragView } from "@/timeline"; interface TimelineTrackContentProps { track: TimelineTrack; zoomLevel: number; - dragState: ElementDragState; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - lastMouseXRef: React.RefObject; + dragView: ElementDragView; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; @@ -42,10 +36,7 @@ interface TimelineTrackContentProps { export function TimelineTrackContent({ track, zoomLevel, - dragState, - rulerScrollRef, - tracksScrollRef, - lastMouseXRef, + dragView, onResizeStart, onElementMouseDown, onElementClick, @@ -55,15 +46,6 @@ export function TimelineTrackContent({ targetElementId = null, }: TimelineTrackContentProps) { const { isElementSelected } = useElementSelection(); - const duration = useEditor((e) => e.timeline.getTotalDuration()); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current ?? 0, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, - }); return (
@@ -120,7 +102,7 @@ export function TimelineTrackContent({ onElementClick={(event, element) => onElementClick({ event, element, track }) } - dragState={dragState} + dragView={dragView} isDropTarget={element.id === targetElementId} /> ); diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts new file mode 100644 index 00000000..7fca2676 --- /dev/null +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -0,0 +1,577 @@ +import type { DragEvent } from "react"; +import { processMediaAssets } from "@/media/processing"; +import { showMediaUploadToast } from "@/media/upload-toast"; +import { + DEFAULT_NEW_ELEMENT_DURATION, + toElementDurationTicks, +} from "@/timeline/creation"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import type { FrameRate } from "opencut-wasm"; +import { + buildTextElement, + buildGraphicElement, + buildStickerElement, + buildElementFromMedia, + buildEffectElement, +} from "@/timeline/element-utils"; +import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; +import { BatchCommand } from "@/commands"; +import type { Command } from "@/commands/base-command"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import type { TimelineDragSource } from "@/timeline/drag-source"; +import type { + TrackType, + DropTarget, + ElementType, + SceneTracks, + TimelineTrack, + CreateTimelineElement, +} from "@/timeline"; +import type { TimelineDragData } from "@/timeline/drag"; +import type { MediaAsset } from "@/media/types"; +import type { ProcessedMediaAsset } from "@/media/processing"; +import { roundFrameTime, type MediaTime } from "@/wasm"; + +// --- Config --- + +export interface DragDropConfig { + zoomLevel: number; + getContainerEl: () => HTMLDivElement | null; + getHeaderEl: () => HTMLElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + getActiveProjectId: () => string | null; + getSceneTracks: () => SceneTracks; + getCurrentPlayheadTime: () => MediaTime; + getMediaAssets: () => MediaAsset[]; + dragSource: TimelineDragSource; + addMediaAsset: (args: { + projectId: string; + asset: ProcessedMediaAsset; + }) => Promise; + executeCommand: (command: Command) => void; + insertElement: (args: { + placement: { mode: "explicit"; trackId: string }; + element: CreateTimelineElement; + }) => void; + addClipEffect: (args: { + trackId: string; + elementId: string; + effectType: string; + }) => void; +} + +export interface DragDropConfigRef { + readonly current: DragDropConfig; +} + +// --- State --- + +interface DragOverState { + kind: "over"; + dropTarget: DropTarget | null; + elementType: ElementType | null; +} + +type DragDropState = { kind: "idle" } | DragOverState; + +interface TimelineCoords { + mouseX: number; + mouseY: number; +} + +// --- Pure helpers --- + +function elementTypeFromDrag({ + dragData, +}: { + dragData: TimelineDragData; +}): ElementType { + switch (dragData.type) { + case "text": + return "text"; + case "graphic": + return "graphic"; + case "sticker": + return "sticker"; + case "effect": + return "effect"; + case "media": + return dragData.mediaType; + } +} + +function getTargetElementTypesForDrag({ + dragData, +}: { + dragData: TimelineDragData; +}): string[] | undefined { + if (dragData.type === "effect") return dragData.targetElementTypes; + if (dragData.type === "media") return dragData.targetElementTypes; + return undefined; +} + +function getDurationForDrag({ + dragData, + mediaAssets, +}: { + dragData: TimelineDragData; + mediaAssets: MediaAsset[]; +}): MediaTime { + if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION; + const media = mediaAssets.find((asset) => asset.id === dragData.id); + return toElementDurationTicks({ seconds: media?.duration }); +} + +function orderedTracks({ + sceneTracks, +}: { + sceneTracks: SceneTracks; +}): TimelineTrack[] { + return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; +} + +// --- Controller --- + +export class DragDropController { + private state: DragDropState = { kind: "idle" }; + private enterCount = 0; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: DragDropConfigRef; + + constructor(deps: { configRef: DragDropConfigRef }) { + this.configRef = deps.configRef; + this.onDragEnter = this.onDragEnter.bind(this); + this.onDragOver = this.onDragOver.bind(this); + this.onDragLeave = this.onDragLeave.bind(this); + this.onDrop = this.onDrop.bind(this); + } + + private get config(): DragDropConfig { + return this.configRef.current; + } + + get isDragOver(): boolean { + return this.state.kind !== "idle"; + } + + get dropTarget(): DropTarget | null { + return this.state.kind === "over" ? this.state.dropTarget : null; + } + + get dragElementType(): ElementType | null { + return this.state.kind === "over" ? this.state.elementType : null; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + this.subscribers.clear(); + } + + // --- Drag event handlers (bound, stable, passed as React props) --- + + onDragEnter(event: DragEvent): void { + event.preventDefault(); + const hasAsset = this.config.dragSource.isActive(); + const hasFiles = event.dataTransfer.types.includes("Files"); + if (!hasAsset && !hasFiles) return; + + this.enterCount += 1; + if (this.state.kind === "idle") { + this.setOver({ dropTarget: null, elementType: null }); + } + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + + const coords = this.getMouseTimelineCoords({ event }); + if (!coords) return; + + const dragData = this.config.dragSource.getActive(); + const hasFiles = event.dataTransfer.types.includes("Files"); + const isExternal = hasFiles && !dragData; + + if (!dragData) { + if (hasFiles && isExternal) { + this.setOver({ dropTarget: null, elementType: null }); + } + return; + } + + const elementType = elementTypeFromDrag({ dragData }); + const duration = getDurationForDrag({ + dragData, + mediaAssets: this.config.getMediaAssets(), + }); + const targetElementTypes = getTargetElementTypesForDrag({ dragData }); + + const sceneTracks = this.config.getSceneTracks(); + const target = computeDropTarget({ + elementType, + mouseX: coords.mouseX, + mouseY: coords.mouseY, + tracks: sceneTracks, + playheadTime: this.config.getCurrentPlayheadTime(), + isExternalDrop: isExternal, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + targetElementTypes, + }); + + const fps = this.config.getActiveProjectFps(); + target.xPosition = fps + ? roundFrameTime({ time: target.xPosition, fps }) + : target.xPosition; + + this.setOver({ dropTarget: target, elementType }); + event.dataTransfer.dropEffect = "copy"; + } + + onDragLeave(event: DragEvent): void { + event.preventDefault(); + if (this.enterCount === 0) return; + this.enterCount -= 1; + if (this.enterCount === 0) { + this.setIdle(); + } + } + + onDrop(event: DragEvent): void { + event.preventDefault(); + this.enterCount = 0; + + const dragData = this.config.dragSource.getActive(); + const hasFiles = event.dataTransfer.files?.length > 0; + if (!dragData && !hasFiles) return; + + const currentTarget = this.dropTarget; + this.setIdle(); + + try { + if (dragData) { + if (!currentTarget) return; + this.executeAssetDrop({ target: currentTarget, dragData }); + return; + } + + const coords = this.getMouseTimelineCoords({ event }); + if (!coords) return; + this.executeFileDrop({ + files: Array.from(event.dataTransfer.files), + mouseX: coords.mouseX, + mouseY: coords.mouseY, + }).catch((error) => { + console.error("Failed to process file drop:", error); + }); + } catch (error) { + console.error("Failed to process drop:", error); + } + } + + // --- Private --- + + private setOver(state: { + dropTarget: DropTarget | null; + elementType: ElementType | null; + }): void { + this.state = { kind: "over", ...state }; + this.notify(); + } + + private setIdle(): void { + this.state = { kind: "idle" }; + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private getMouseTimelineCoords({ + event, + }: { + event: DragEvent; + }): TimelineCoords | null { + const scrollContainer = this.config.getTracksScrollEl(); + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + this.config.getContainerEl()?.getBoundingClientRect(); + if (!referenceRect) return null; + + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const headerHeight = + this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0; + + return { + mouseX: event.clientX - referenceRect.left + scrollLeft, + mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight, + }; + } + + // Shared insertion logic — new track vs existing track. + private insertAtTarget({ + element, + target, + trackType, + }: { + element: CreateTimelineElement; + target: DropTarget; + trackType: TrackType; + }): void { + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + this.config.executeCommand( + new BatchCommand([ + addTrackCmd, + new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }), + ]), + ); + return; + } + + const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() }); + const track = tracks[target.trackIndex]; + if (!track) return; + this.config.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + } + + private executeAssetDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: TimelineDragData; + }): void { + switch (dragData.type) { + case "text": + this.executeTextDrop({ target, dragData }); + return; + case "graphic": + this.executeGraphicDrop({ target, dragData }); + return; + case "sticker": + this.executeStickerDrop({ target, dragData }); + return; + case "effect": + this.executeEffectDrop({ target, dragData }); + return; + case "media": + this.executeMediaDrop({ target, dragData }); + return; + } + } + + private executeTextDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildTextElement({ + raw: { name: dragData.name ?? "", content: dragData.content ?? "" }, + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType: "text" }); + } + + private executeStickerDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildStickerElement({ + stickerId: dragData.stickerId, + name: dragData.name, + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType: "graphic" }); + } + + private executeGraphicDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildGraphicElement({ + definitionId: dragData.definitionId, + name: dragData.name, + startTime: target.xPosition, + params: dragData.params, + }); + this.insertAtTarget({ element, target, trackType: "graphic" }); + } + + private executeMediaDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + if (target.targetElement) { + // Replace media source — not yet implemented + return; + } + + const mediaAsset = this.config + .getMediaAssets() + .find((asset) => asset.id === dragData.id); + if (!mediaAsset) return; + + const trackType: TrackType = + dragData.mediaType === "audio" ? "audio" : "video"; + const element = buildElementFromMedia({ + mediaId: mediaAsset.id, + mediaType: mediaAsset.type, + name: mediaAsset.name, + duration: toElementDurationTicks({ seconds: mediaAsset.duration }), + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType }); + } + + private executeEffectDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + if (target.targetElement) { + this.config.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + + const existingEffectTrack = orderedTracks({ + sceneTracks: this.config.getSceneTracks(), + }).find((track) => track.type === "effect"); + + if (existingEffectTrack) { + this.config.insertElement({ + placement: { mode: "explicit", trackId: existingEffectTrack.id }, + element, + }); + return; + } + + this.insertAtTarget({ element, target, trackType: "effect" }); + } + + private async executeFileDrop({ + files, + mouseX, + mouseY, + }: { + files: File[]; + mouseX: number; + mouseY: number; + }): Promise { + const projectId = this.config.getActiveProjectId(); + if (!projectId) return; + + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + + // Sequential on purpose: each iteration reads getSceneTracks() + // to decide placement (reuse empty main vs new track) and that + // decision depends on the effects of prior inserts. + for (const asset of processedAssets) { + const createdAsset = await this.config.addMediaAsset({ + projectId, + asset, + }); + if (!createdAsset) continue; + + const duration = toElementDurationTicks({ + seconds: createdAsset.duration, + }); + + const sceneTracks = this.config.getSceneTracks(); + const currentTime = this.config.getCurrentPlayheadTime(); + + const reuseMainTrackId = + createdAsset.type !== "audio" && + sceneTracks.overlay.length === 0 && + sceneTracks.audio.length === 0 && + sceneTracks.main.elements.length === 0 + ? sceneTracks.main.id + : null; + + if (reuseMainTrackId) { + this.config.insertElement({ + placement: { mode: "explicit", trackId: reuseMainTrackId }, + element: buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: currentTime, + }), + }); + continue; + } + + const dropTarget = computeDropTarget({ + elementType: createdAsset.type, + mouseX, + mouseY, + tracks: sceneTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + }); + + const trackType: TrackType = + createdAsset.type === "audio" ? "audio" : "video"; + this.insertAtTarget({ + element: buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: dropTarget.xPosition, + }), + target: dropTarget, + trackType, + }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + } +} diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts new file mode 100644 index 00000000..d0d6426a --- /dev/null +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -0,0 +1,702 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, + type GroupMoveResult, + type MoveGroup, +} from "@/timeline/group-move"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { + maxMediaTime, + type MediaTime, + mediaTime, + roundFrameTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import type { FrameRate } from "opencut-wasm"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { generateUUID } from "@/utils/id"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { + DropTarget, + ElementRef, + ElementDragView, + SceneTracks, + TimelineElement, + TimelineTrack, +} from "@/timeline"; + +const MOUSE_BUTTON_RIGHT = 2; + +// --- Config --- + +export interface ViewportAdapter { + getZoomLevel: () => number; + getTracksScrollEl: () => HTMLDivElement | null; + getTracksContainerEl: () => HTMLDivElement | null; + getHeaderEl: () => HTMLElement | null; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getTracks: () => SceneTracks; + getActiveFps: () => FrameRate | null; +} + +export interface ElementSelectionApi { + getSelected: () => readonly ElementRef[]; + isSelected: (ref: ElementRef) => boolean; + select: (ref: ElementRef) => void; + handleClick: (args: ElementRef & { isMultiKey: boolean }) => void; + clearKeyframeSelection: () => void; +} + +export interface PlaybackReader { + getCurrentTime: () => MediaTime; +} + +export interface TimelineOps { + moveElements: (args: Pick) => void; +} + +export interface SnapConfig { + isEnabled: () => boolean; + onChange?: (snapPoint: SnapPoint | null) => void; +} + +export interface ElementInteractionDeps { + viewport: ViewportAdapter; + input: InputAdapter; + scene: SceneReader; + selection: ElementSelectionApi; + playback: PlaybackReader; + timeline: TimelineOps; + snap: SnapConfig; +} + +export interface ElementInteractionDepsRef { + readonly current: ElementInteractionDeps; +} + +// --- Session --- + +type Point = { readonly x: number; readonly y: number }; + +interface MousedownSnapshot { + readonly origin: Point; + readonly elementId: string; + readonly trackId: string; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; + readonly selectedElements: readonly ElementRef[]; +} + +interface DragProgress { + moveGroup: MoveGroup; + // Pre-minted per member so the identity of any "new track" created by + // this drag stays stable across mousemove-driven drop-target recomputes. + // `resolveGroupMoveForDrop` runs every mousemove and emits a + // `createTracks[]` carrying these IDs; downstream consumers (snap + // indicator, drop-line, commit path) see the same entity every frame + // instead of a churning UUID. + reservedNewTrackIds: readonly string[]; + currentTime: MediaTime; + currentMouseX: number; + currentMouseY: number; + groupMoveResult: GroupMoveResult | null; + dropTarget: DropTarget | null; +} + +type Session = + | { kind: "idle" } + | { kind: "pending"; mousedown: MousedownSnapshot } + | { kind: "dragging"; mousedown: MousedownSnapshot; drag: DragProgress }; + +const IDLE_VIEW: ElementDragView = { kind: "idle" }; + +// --- Pure helpers --- + +function pixelToClickOffsetTime( + clientX: number, + elementRect: DOMRect, + zoomLevel: number, +): MediaTime { + const clickOffsetX = clientX - elementRect.left; + const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); +} + +function verticalDirection( + startMouseY: number, + currentMouseY: number, +): "up" | "down" | null { + if (currentMouseY < startMouseY) return "up"; + if (currentMouseY > startMouseY) return "down"; + return null; +} + +function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { + return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; +} + +function movedPastDragThreshold(current: Point, origin: Point): boolean { + return ( + Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX || + Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX + ); +} + +function frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel, + clickOffsetTime, + fps, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + clickOffsetTime: MediaTime; + fps: FrameRate; +}): MediaTime { + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + }); + const adjusted = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: mouseTime, b: clickOffsetTime }), + }); + return roundFrameTime({ time: adjusted, fps }); +} + +function resolveDropTarget({ + clientX, + clientY, + elementId, + trackId, + tracks, + viewport, + zoomLevel, + snappedTime, + verticalDragDirection, +}: { + clientX: number; + clientY: number; + elementId: string; + trackId: string; + tracks: SceneTracks; + viewport: ViewportAdapter; + zoomLevel: number; + snappedTime: MediaTime; + verticalDragDirection: "up" | "down" | null; +}): DropTarget | null { + const containerRect = viewport + .getTracksContainerEl() + ?.getBoundingClientRect(); + const scrollContainer = viewport.getTracksScrollEl(); + if (!containerRect || !scrollContainer) return null; + + const sourceTrack = orderedTracks(tracks).find(({ id }) => id === trackId); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === elementId, + ); + if (!movingElement) return null; + + const scrollRect = scrollContainer.getBoundingClientRect(); + const headerHeight = + viewport.getHeaderEl()?.getBoundingClientRect().height ?? 0; + + return computeDropTarget({ + elementType: movingElement.type, + mouseX: clientX - scrollRect.left + scrollContainer.scrollLeft, + mouseY: clientY - scrollRect.top + scrollContainer.scrollTop - headerHeight, + tracks, + playheadTime: snappedTime, + isExternalDrop: false, + elementDuration: movingElement.duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel, + startTimeOverride: snappedTime, + excludeElementId: movingElement.id, + verticalDragDirection, + }); +} + +function resolveGroupMoveForDrop({ + group, + tracks, + anchorStartTime, + dropTarget, + reservedNewTrackIds, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: MediaTime; + dropTarget: DropTarget; + reservedNewTrackIds: readonly string[]; +}): GroupMoveResult | null { + const newTracksFallback = () => + resolveGroupMove({ + group, + tracks, + anchorStartTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: [...reservedNewTrackIds], + }, + }); + + if (dropTarget.isNewTrack) return newTracksFallback(); + + const targetTrack = orderedTracks(tracks)[dropTarget.trackIndex]; + if (!targetTrack) return null; + + return ( + resolveGroupMove({ + group, + tracks, + anchorStartTime, + target: { kind: "existingTrack", anchorTargetTrackId: targetTrack.id }, + }) ?? newTracksFallback() + ); +} + +// --- Controller --- + +export class ElementInteractionController { + private session: Session = { kind: "idle" }; + // True once the active gesture crossed the drag threshold. Read by + // onElementClick, which fires after mouseup — by which point the session + // has already returned to idle, so the "was this a drag?" answer must + // outlive the session. Reset on the next mousedown. + private lastGestureWasDrag = false; + + private readonly subscribers = new Set<() => void>(); + private readonly depsRef: ElementInteractionDepsRef; + + constructor(args: { depsRef: ElementInteractionDepsRef }) { + this.depsRef = args.depsRef; + } + + private get deps(): ElementInteractionDeps { + return this.depsRef.current; + } + + get view(): ElementDragView { + if (this.session.kind !== "dragging") return IDLE_VIEW; + const { mousedown, drag } = this.session; + const memberTimeOffsets = new Map(); + for (const member of drag.moveGroup.members) { + memberTimeOffsets.set(member.elementId, member.timeOffset); + } + return { + kind: "dragging", + anchorElementId: mousedown.elementId, + trackId: mousedown.trackId, + memberTimeOffsets, + startMouseX: mousedown.origin.x, + startMouseY: mousedown.origin.y, + startElementTime: mousedown.startElementTime, + clickOffsetTime: mousedown.clickOffsetTime, + currentTime: drag.currentTime, + currentMouseX: drag.currentMouseX, + currentMouseY: drag.currentMouseY, + dropTarget: drag.dropTarget, + }; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel = (): void => { + this.lastGestureWasDrag = false; + this.finishSession(); + }; + + destroy(): void { + this.cancel(); + this.subscribers.clear(); + } + + onElementMouseDown = ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }): void => { + // Right-click must not stopPropagation — ContextMenu needs the bubble. + if (event.button === MOUSE_BUTTON_RIGHT) { + const ref = { trackId: track.id, elementId: element.id }; + if (!this.deps.selection.isSelected(ref)) { + this.deps.selection.handleClick({ ...ref, isMultiKey: false }); + } + return; + } + + event.stopPropagation(); + this.lastGestureWasDrag = false; + + const ref = { trackId: track.id, elementId: element.id }; + + if (event.metaKey || event.ctrlKey || event.shiftKey) { + this.deps.selection.handleClick({ ...ref, isMultiKey: true }); + } + + const selectedElements = this.deps.selection.isSelected(ref) + ? this.deps.selection.getSelected() + : [ref]; + + this.session = { + kind: "pending", + mousedown: { + origin: { x: event.clientX, y: event.clientY }, + elementId: element.id, + trackId: track.id, + startElementTime: element.startTime, + clickOffsetTime: pixelToClickOffsetTime( + event.clientX, + event.currentTarget.getBoundingClientRect(), + this.deps.viewport.getZoomLevel(), + ), + selectedElements, + }, + }; + this.activate(); + this.notify(); + }; + + onElementClick = ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }): void => { + event.stopPropagation(); + + if (this.lastGestureWasDrag) { + this.lastGestureWasDrag = false; + return; + } + + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + const ref = { trackId: track.id, elementId: element.id }; + if ( + !this.deps.selection.isSelected(ref) || + this.deps.selection.getSelected().length > 1 + ) { + this.deps.selection.select(ref); + return; + } + + this.deps.selection.clearKeyframeSelection(); + }; + + private activate(): void { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private finishSession(): void { + this.session = { kind: "idle" }; + this.deactivate(); + this.deps.snap.onChange?.(null); + this.notify(); + } + + private snapResult( + frameSnappedTime: MediaTime, + group: MoveGroup, + ): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { + const { snap, input, scene, viewport, playback } = this.deps; + + if (!snap.isEnabled() || input.isShiftHeld()) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + const result = snapGroupEdges({ + group, + anchorStartTime: frameSnappedTime, + tracks: scene.getTracks(), + playheadTime: playback.getCurrentTime(), + zoomLevel: viewport.getZoomLevel(), + }); + + return { + snappedTime: result.snappedAnchorStartTime, + snapPoint: result.snapPoint, + }; + } + + private updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }: { + clientX: number; + clientY: number; + mousedown: MousedownSnapshot; + drag: DragProgress; + snappedTime: MediaTime; + }): void { + const { scene, viewport } = this.deps; + const tracks = scene.getTracks(); + const zoomLevel = viewport.getZoomLevel(); + + const anchorDropTarget = resolveDropTarget({ + clientX, + clientY, + elementId: mousedown.elementId, + trackId: mousedown.trackId, + tracks, + viewport, + zoomLevel, + snappedTime, + verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + }); + + const nextGroupMoveResult = anchorDropTarget + ? resolveGroupMoveForDrop({ + group: drag.moveGroup, + tracks, + anchorStartTime: snappedTime, + dropTarget: anchorDropTarget, + reservedNewTrackIds: drag.reservedNewTrackIds, + }) + : null; + + drag.groupMoveResult = nextGroupMoveResult; + drag.dropTarget = + anchorDropTarget && (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { ...anchorDropTarget, isNewTrack: true } + : null; + } + + private handleMouseMove = ({ clientX, clientY }: MouseEvent): void => { + const scrollContainer = this.deps.viewport.getTracksScrollEl(); + if (!scrollContainer) return; + + if (this.session.kind === "pending") { + this.beginDragFromPending({ + mousedown: this.session.mousedown, + clientX, + clientY, + scrollContainer, + }); + return; + } + + if (this.session.kind === "dragging") { + this.updateActiveDrag({ + mousedown: this.session.mousedown, + drag: this.session.drag, + clientX, + clientY, + scrollContainer, + }); + } + }; + + private beginDragFromPending({ + mousedown, + clientX, + clientY, + scrollContainer, + }: { + mousedown: MousedownSnapshot; + clientX: number; + clientY: number; + scrollContainer: HTMLDivElement; + }): void { + if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + return; + } + + const fps = this.deps.scene.getActiveFps(); + if (!fps) return; + + const moveGroup = buildMoveGroup({ + anchorRef: { + trackId: mousedown.trackId, + elementId: mousedown.elementId, + }, + selectedElements: [...mousedown.selectedElements], + tracks: this.deps.scene.getTracks(), + }); + if (!moveGroup) return; + + const zoomLevel = this.deps.viewport.getZoomLevel(); + const frameSnappedTime = frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel, + clickOffsetTime: mousedown.clickOffsetTime, + fps, + }); + const { snappedTime, snapPoint } = this.snapResult( + frameSnappedTime, + moveGroup, + ); + + // Ensure the anchor is selected before we render the drag — covers the + // case where the selection store hasn't committed the mousedown-time + // selection click yet. + const anchorRef = { + trackId: mousedown.trackId, + elementId: mousedown.elementId, + }; + if (!this.deps.selection.isSelected(anchorRef)) { + this.deps.selection.select(anchorRef); + } + + const drag: DragProgress = { + moveGroup, + reservedNewTrackIds: moveGroup.members.map(() => generateUUID()), + currentTime: snappedTime, + currentMouseX: clientX, + currentMouseY: clientY, + groupMoveResult: null, + dropTarget: null, + }; + + this.session = { kind: "dragging", mousedown, drag }; + this.lastGestureWasDrag = true; + + this.updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }); + + this.deps.snap.onChange?.(snapPoint); + this.notify(); + } + + private updateActiveDrag({ + mousedown, + drag, + clientX, + clientY, + scrollContainer, + }: { + mousedown: MousedownSnapshot; + drag: DragProgress; + clientX: number; + clientY: number; + scrollContainer: HTMLDivElement; + }): void { + const fps = this.deps.scene.getActiveFps(); + if (!fps) return; + + const frameSnappedTime = frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel: this.deps.viewport.getZoomLevel(), + clickOffsetTime: mousedown.clickOffsetTime, + fps, + }); + const { snappedTime, snapPoint } = this.snapResult( + frameSnappedTime, + drag.moveGroup, + ); + + drag.currentTime = snappedTime; + drag.currentMouseX = clientX; + drag.currentMouseY = clientY; + + this.updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }); + + this.deps.snap.onChange?.(snapPoint); + this.notify(); + } + + private handleMouseUp = ({ clientX, clientY }: MouseEvent): void => { + if (this.session.kind === "pending") { + this.finishSession(); + return; + } + + if (this.session.kind !== "dragging") return; + + const { mousedown, drag } = this.session; + + // If the drag returned within the click threshold of its origin, treat + // this as a cancel rather than a commit — the user dragged then put the + // element back. + if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + this.lastGestureWasDrag = false; + this.finishSession(); + return; + } + + const { moveGroup, groupMoveResult } = drag; + if (!groupMoveResult) { + this.finishSession(); + return; + } + + const didMove = groupMoveResult.moves.some((move) => { + const member = moveGroup.members.find( + (m) => m.elementId === move.elementId, + ); + const originalStartTime = + mousedown.startElementTime + (member?.timeOffset ?? 0); + return ( + member?.trackId !== move.targetTrackId || + originalStartTime !== move.newStartTime + ); + }); + + if (didMove || groupMoveResult.createTracks.length > 0) { + this.deps.timeline.moveElements({ + moves: groupMoveResult.moves, + createTracks: groupMoveResult.createTracks, + }); + } + + this.finishSession(); + }; +} diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts new file mode 100644 index 00000000..29dfc5dd --- /dev/null +++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts @@ -0,0 +1,358 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import type { FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { + addMediaTime, + clampMediaTime, + type MediaTime, + mediaTime, + roundFrameTicks, + snapSeekMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { getKeyframeById } from "@/animation"; +import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/commands"; +import type { SelectedKeyframeRef } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import type { Command } from "@/commands/base-command"; + +// --- Session --- + +interface PendingSession { + kind: "pending"; + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +interface ActiveSession { + kind: "active"; + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; + deltaTicks: number; +} + +type Session = { kind: "idle" } | PendingSession | ActiveSession; + +// --- Public state --- + +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set; + deltaTicks: number; +} + +const IDLE_DRAG_STATE: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTicks: 0, +}; + +// --- Config --- + +export interface KeyframeDragConfig { + zoomLevel: number; + getFps: () => FrameRate | null; + element: TimelineElement; + displayedStartTime: MediaTime; + selectedKeyframes: SelectedKeyframeRef[]; + isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean; + setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void; + toggleKeyframeSelection: (args: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => void; + selectKeyframeRange: (args: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => void; + executeCommand: (command: Command) => void; + seek: (args: { time: MediaTime }) => void; + getTotalDuration: () => MediaTime; +} + +export interface KeyframeDragConfigRef { + readonly current: KeyframeDragConfig; +} + +// --- Controller --- + +export class KeyframeDragController { + private session: Session = { kind: "idle" }; + // Persists through mouseup so the click handler can detect drag vs click + private mouseDownX: number | null = null; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: KeyframeDragConfigRef; + + constructor(deps: { configRef: KeyframeDragConfigRef }) { + this.configRef = deps.configRef; + this.onKeyframeMouseDown = this.onKeyframeMouseDown.bind(this); + this.onKeyframeClick = this.onKeyframeClick.bind(this); + this.getVisualOffsetPx = this.getVisualOffsetPx.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): KeyframeDragConfig { + return this.configRef.current; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + get keyframeDragState(): KeyframeDragState { + if (this.session.kind !== "active") return IDLE_DRAG_STATE; + return { + isDragging: true, + draggingKeyframeIds: new Set( + this.session.keyframeRefs.map((kf) => kf.keyframeId), + ), + deltaTicks: this.session.deltaTicks, + }; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel(): void { + this.mouseDownX = null; + this.finishSession(); + } + + destroy(): void { + this.deactivate(); + this.subscribers.clear(); + } + + onKeyframeMouseDown({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }): void { + event.preventDefault(); + event.stopPropagation(); + + this.mouseDownX = event.clientX; + + const anySelected = keyframes.some((kf) => + this.config.isKeyframeSelected({ keyframe: kf }), + ); + const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; + + if (!anySelected && !isModifierKey) { + this.config.setKeyframeSelection({ keyframes }); + } + + this.session = { + kind: "pending", + keyframeRefs: anySelected ? this.config.selectedKeyframes : keyframes, + startMouseX: event.clientX, + }; + this.activate(); + this.notify(); + } + + onKeyframeClick({ + event, + keyframes, + orderedKeyframes, + indicatorTime, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: MediaTime; + }): void { + event.stopPropagation(); + + const wasDrag = + this.mouseDownX !== null && + Math.abs(event.clientX - this.mouseDownX) > TIMELINE_DRAG_THRESHOLD_PX; + this.mouseDownX = null; + + if (wasDrag) return; + + const { displayedStartTime, getFps, getTotalDuration, seek } = this.config; + const fps = getFps(); + const absoluteIndicatorTime = addMediaTime({ + a: displayedStartTime, + b: indicatorTime, + }); + const seekTime = + fps != null + ? snapSeekMediaTime({ + time: absoluteIndicatorTime, + duration: getTotalDuration(), + fps, + }) + : absoluteIndicatorTime; + seek({ time: seekTime }); + + if (event.shiftKey) { + this.config.selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + this.config.toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + } + + getVisualOffsetPx({ + indicatorTime, + indicatorOffsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }: { + indicatorTime: MediaTime; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: MediaTime; + elementLeft: number; + }): number { + if (!isBeingDragged || this.session.kind !== "active") + return indicatorOffsetPx; + const deltaTime = mediaTime({ ticks: this.session.deltaTicks }); + const clampedTime = clampMediaTime({ + time: addMediaTime({ a: indicatorTime, b: deltaTime }), + min: ZERO_MEDIA_TIME, + max: this.config.element.duration, + }); + return ( + timelineTimeToSnappedPixels({ + time: addMediaTime({ a: displayedStartTime, b: clampedTime }), + zoomLevel: this.config.zoomLevel, + }) - elementLeft + ); + } + + private activate(): void { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private finishSession(): void { + this.session = { kind: "idle" }; + this.deactivate(); + this.notify(); + } + + private commitDrag({ + keyframeRefs, + deltaTicks, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTicks: number; + }): void { + const { element } = this.config; + const commands: Command[] = keyframeRefs.flatMap((ref) => { + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: ref.propertyPath, + keyframeId: ref.keyframeId, + }); + if (!keyframe) return []; + return [ + new RetimeKeyframeCommand({ + trackId: ref.trackId, + elementId: ref.elementId, + propertyPath: ref.propertyPath, + keyframeId: ref.keyframeId, + nextTime: clampMediaTime({ + time: addMediaTime({ + a: keyframe.time, + b: mediaTime({ ticks: deltaTicks }), + }), + min: ZERO_MEDIA_TIME, + max: element.duration, + }), + }), + ]; + }); + + const [first, ...rest] = commands; + if (!first) return; + if (rest.length === 0) { + this.config.executeCommand(first); + } else { + this.config.executeCommand(new BatchCommand([first, ...rest])); + } + } + + private handleMouseMove({ clientX }: MouseEvent): void { + if (this.session.kind === "pending") { + const deltaX = Math.abs(clientX - this.session.startMouseX); + if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; + + this.session = { + kind: "active", + keyframeRefs: this.session.keyframeRefs, + startMouseX: this.session.startMouseX, + deltaTicks: 0, + }; + this.notify(); + return; + } + + if (this.session.kind !== "active") return; + + const fps = this.config.getFps(); + if (!fps) return; + + const pixelsPerSecond = + BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel; + const rawDeltaTicks = Math.round( + ((clientX - this.session.startMouseX) / pixelsPerSecond) * + TICKS_PER_SECOND, + ); + this.session.deltaTicks = + roundFrameTicks({ ticks: rawDeltaTicks, fps }); + this.notify(); + } + + private handleMouseUp(): void { + if (this.session.kind === "pending") { + this.finishSession(); + return; + } + + if (this.session.kind !== "active") return; + + const { selectedKeyframes, element } = this.config; + const { keyframeRefs, deltaTicks } = this.session; + const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId)); + const draggingRefs = selectedKeyframes.filter( + (kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId), + ); + + if (draggingRefs.length > 0 && deltaTicks !== 0) { + this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks }); + } + + this.finishSession(); + } +} diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts new file mode 100644 index 00000000..b96b868b --- /dev/null +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -0,0 +1,318 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import type { FrameRate } from "opencut-wasm"; +import { + mediaTime, + snapSeekMediaTime, + TICKS_PER_SECOND, + type MediaTime, +} from "@/wasm"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, +} from "@/timeline/snapping"; +import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + getCenteredLineLeft, + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import type { Bookmark, SceneTracks } from "@/timeline"; + +// --- Session --- + +interface ScrubSession { + kind: "scrubbing"; + /** True when scrub started from a ruler click (not the playhead handle). */ + didStartFromRuler: boolean; + /** True once the mouse has moved during a ruler drag. */ + hasMoved: boolean; + /** Most recent frame-snapped time set by scrub(). */ + currentTime: MediaTime | null; +} + +type Session = { kind: "idle" } | ScrubSession; + +// --- Config --- + +export interface PlayheadConfig { + zoomLevel: number; + duration: MediaTime; + getActiveProjectFps: () => FrameRate | null; + isShiftHeld: () => boolean; + getIsPlaying: () => boolean; + getRulerEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getPlayheadEl: () => HTMLDivElement | null; + getSceneTracks: () => SceneTracks; + getSceneBookmarks: () => Bookmark[]; + seek: (time: MediaTime) => void; + setScrubbing: (isScrubbing: boolean) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: MediaTime; + }) => void; +} + +export interface PlayheadConfigRef { + readonly current: PlayheadConfig; +} + +// --- Pure helpers (px → logical) --- + +function pixelToTime({ + clientX, + rulerEl, + zoomLevel, + duration, +}: { + clientX: number; + rulerEl: HTMLDivElement; + zoomLevel: number; + duration: MediaTime; +}): MediaTime { + const rulerRect = rulerEl.getBoundingClientRect(); + const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel }); + const clampedX = Math.max( + 0, + Math.min(contentWidth, clientX - rulerRect.left), + ); + const seconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); +} + +// --- Controller --- + +export class PlayheadController { + private lastMouseClientX = 0; + + private session: Session = { kind: "idle" }; + private readonly configRef: PlayheadConfigRef; + + constructor(deps: { configRef: PlayheadConfigRef }) { + this.configRef = deps.configRef; + this.onPlayheadMouseDown = this.onPlayheadMouseDown.bind(this); + this.onRulerMouseDown = this.onRulerMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): PlayheadConfig { + return this.configRef.current; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + getLastMouseClientX(): number { + return this.lastMouseClientX; + } + + destroy(): void { + this.deactivate(); + } + + // --- Public event handlers (bound, stable references) --- + + onPlayheadMouseDown(event: ReactMouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.session = { + kind: "scrubbing", + didStartFromRuler: false, + hasMoved: false, + currentTime: null, + }; + this.config.setScrubbing(true); + this.scrub({ event, isElementSnappingEnabled: true }); + this.activate(); + } + + onRulerMouseDown(event: ReactMouseEvent): void { + if (event.button !== 0) return; + if (this.config.getPlayheadEl()?.contains(event.target as Node)) return; + + event.preventDefault(); + this.session = { + kind: "scrubbing", + didStartFromRuler: true, + hasMoved: false, + currentTime: null, + }; + this.config.setScrubbing(true); + // No element-edge snapping on initial ruler click — avoids a jarring jump. + this.scrub({ event, isElementSnappingEnabled: false }); + this.activate(); + } + + // --- Public non-session methods --- + + /** + * Imperatively updates the playhead DOM element's `left` style. + * Called on scroll and playback events to avoid React re-renders + * during animation frame updates. + */ + updatePlayheadLeft(time: MediaTime): void { + const playheadEl = this.config.getPlayheadEl(); + if (!playheadEl) return; + + const centerPixel = timelineTimeToSnappedPixels({ + time, + zoomLevel: this.config.zoomLevel, + }); + const scrollLeft = this.config.getRulerScrollEl()?.scrollLeft ?? 0; + playheadEl.style.left = `${getCenteredLineLeft({ centerPixel }) - scrollLeft}px`; + } + + /** + * Updates the playhead position and auto-scrolls to keep the playhead + * visible during playback. + */ + handlePlaybackUpdate(time: MediaTime): void { + this.updatePlayheadLeft(time); + + // Auto-scroll only during playback, not while scrubbing. + if (!this.config.getIsPlaying() || this.session.kind === "scrubbing") + return; + + const rulerViewport = this.config.getRulerScrollEl(); + const tracksViewport = this.config.getTracksScrollEl(); + if (!rulerViewport || !tracksViewport) return; + + const playheadPixels = timelineTimeToPixels({ + time, + zoomLevel: this.config.zoomLevel, + }); + const viewportWidth = rulerViewport.clientWidth; + const isOutOfView = + playheadPixels < rulerViewport.scrollLeft || + playheadPixels > rulerViewport.scrollLeft + viewportWidth; + + if (isOutOfView) { + const desiredScroll = Math.max( + 0, + Math.min( + rulerViewport.scrollWidth - viewportWidth, + playheadPixels - viewportWidth / 2, + ), + ); + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + } + + // --- Private --- + + private activate(): void { + window.addEventListener("mousemove", this.handleMouseMove); + window.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + window.removeEventListener("mousemove", this.handleMouseMove); + window.removeEventListener("mouseup", this.handleMouseUp); + } + + /** + * Converts pointer position to a frame-snapped timeline time and seeks. + * `isElementSnappingEnabled` controls element-edge snapping; frame-level snapping + * is always applied. + */ + private scrub({ + event, + isElementSnappingEnabled, + }: { + event: MouseEvent | ReactMouseEvent; + isElementSnappingEnabled: boolean; + }): void { + const ruler = this.config.getRulerEl(); + if (!ruler) return; + + const fps = this.config.getActiveProjectFps(); + if (!fps) return; + + const { zoomLevel, duration } = this.config; + const rawTime = pixelToTime({ + clientX: event.clientX, + rulerEl: ruler, + zoomLevel, + duration, + }); + const frameTime = snapSeekMediaTime({ time: rawTime, duration, fps }); + + const time = (() => { + if (!isElementSnappingEnabled || this.config.isShiftHeld()) + return frameTime; + + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => + getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }), + () => + getBookmarkSnapPoints({ + bookmarks: this.config.getSceneBookmarks(), + }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks: this.config.getSceneTracks(), + }), + ], + }); + const result = resolveTimelineSnap({ + targetTime: frameTime, + snapPoints, + maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), + }); + return result.snapPoint ? result.snappedTime : frameTime; + })(); + + if (this.session.kind === "scrubbing") { + this.session.currentTime = time; + } + this.config.seek(time); + this.lastMouseClientX = event.clientX; + } + + private handleMouseMove(event: MouseEvent): void { + if (this.session.kind !== "scrubbing") return; + this.scrub({ event, isElementSnappingEnabled: true }); + if (this.session.didStartFromRuler) { + this.session.hasMoved = true; + } + } + + private handleMouseUp(event: MouseEvent): void { + if (this.session.kind !== "scrubbing") return; + + const session = this.session; + this.config.setScrubbing(false); + + if (session.currentTime !== null) { + this.config.seek(session.currentTime); + this.config.setTimelineViewState({ + zoomLevel: this.config.zoomLevel, + scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0, + playheadTime: session.currentTime, + }); + } + + // Ruler click without drag: snap to clicked position on mouseup. + if (session.didStartFromRuler && !session.hasMoved) { + this.scrub({ event, isElementSnappingEnabled: false }); + } + + this.session = { kind: "idle" }; + this.deactivate(); + } +} diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts new file mode 100644 index 00000000..8b0119a0 --- /dev/null +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -0,0 +1,360 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + mediaTime, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; +import { + computeGroupResize, + type GroupResizeMember, + type GroupResizeResult, + type GroupResizeUpdate, + type ResizeSide, +} from "@/timeline/group-resize"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, + type SnapPoint, +} from "@/timeline/snapping"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + isRetimableElement, + type SceneTracks, + type TimelineElement, + type TimelineTrack, +} from "@/timeline"; +import type { ElementRef } from "@/timeline/types"; +import type { FrameRate } from "opencut-wasm"; + +// --- Session --- + +interface ResizeSession { + kind: "active"; + side: ResizeSide; + startX: number; + fps: FrameRate; + members: GroupResizeMember[]; + result: GroupResizeResult | null; +} + +type Session = { kind: "idle" } | ResizeSession; + +// --- Config --- + +export interface ResizeConfig { + zoomLevel: number; + snappingEnabled: boolean; + isShiftHeld: () => boolean; + getSceneTracks: () => SceneTracks; + getCurrentPlayheadTime: () => MediaTime; + getActiveProjectFps: () => FrameRate | null; + selectedElements: ElementRef[]; + discardPreview: () => void; + previewElements: (updates: GroupResizeUpdate[]) => void; + commitElements: (updates: GroupResizeUpdate[]) => void; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +export interface ResizeConfigRef { + readonly current: ResizeConfig; +} + +// --- Pure helpers --- + +export function buildResizeMembers({ + tracks, + selectedElements, +}: { + tracks: SceneTracks; + selectedElements: ElementRef[]; +}): GroupResizeMember[] { + const selectedElementIds = new Set( + selectedElements.map((el) => el.elementId), + ); + const trackMap = new Map( + [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ + track.id, + track, + ]), + ); + + return selectedElements.flatMap(({ trackId, elementId }) => { + const track = trackMap.get(trackId); + const element = track?.elements.find((el) => el.id === elementId); + if (!track || !element) return []; + + const otherElements = track.elements.filter( + (el) => !selectedElementIds.has(el.id), + ); + const leftNeighborBound = otherElements + .filter( + (el) => + addMediaTime({ a: el.startTime, b: el.duration }) <= + element.startTime, + ) + .reduce((bound, el) => { + const elementEnd = addMediaTime({ + a: el.startTime, + b: el.duration, + }); + return bound === null + ? elementEnd + : maxMediaTime({ a: bound, b: elementEnd }); + }, null); + const rightNeighborBound = otherElements + .filter( + (el) => + el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }), + ) + .reduce( + (bound, el) => + bound === null + ? el.startTime + : minMediaTime({ a: bound, b: el.startTime }), + null, + ); + + return [ + { + trackId, + elementId, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + sourceDuration: element.sourceDuration, + retime: isRetimableElement(element) ? element.retime : undefined, + leftNeighborBound, + rightNeighborBound, + }, + ]; + }); +} + +function hasResizeChanges({ + members, + result, +}: { + members: GroupResizeMember[]; + result: GroupResizeResult; +}): boolean { + return result.updates.some((update) => { + const member = members.find((m) => m.elementId === update.elementId); + return ( + member?.trimStart !== update.patch.trimStart || + member?.trimEnd !== update.patch.trimEnd || + member?.startTime !== update.patch.startTime || + member?.duration !== update.patch.duration + ); + }); +} + +// --- Controller --- + +export class ResizeController { + private session: Session = { kind: "idle" }; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: ResizeConfigRef; + + constructor(deps: { configRef: ResizeConfigRef }) { + this.configRef = deps.configRef; + this.onResizeStart = this.onResizeStart.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): ResizeConfig { + return this.configRef.current; + } + + get isResizing(): boolean { + return this.session.kind === "active"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel(): void { + this.config.discardPreview(); + this.finishSession(); + } + + destroy(): void { + this.deactivate(); + this.subscribers.clear(); + } + + onResizeStart({ + event, + element, + track, + side, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + side: ResizeSide; + }): void { + event.stopPropagation(); + event.preventDefault(); + + // UI should prevent this, but be explicit: a new resize start + // means the previous one is abandoned, not silently replaced. + if (this.session.kind === "active") this.cancel(); + + const fps = this.config.getActiveProjectFps(); + if (!fps) return; + + const ref = { trackId: track.id, elementId: element.id }; + const activeSelection = this.config.selectedElements.some( + (el) => el.trackId === track.id && el.elementId === element.id, + ) + ? this.config.selectedElements + : [ref]; + + const members = buildResizeMembers({ + tracks: this.config.getSceneTracks(), + selectedElements: activeSelection, + }); + if (members.length === 0) return; + + this.config.discardPreview(); + + this.session = { + kind: "active", + side, + startX: event.clientX, + fps, + members, + result: null, + }; + this.activate(); + this.notify(); + } + + private activate(): void { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private finishSession(): void { + this.session = { kind: "idle" }; + this.deactivate(); + this.config.onSnapPointChange?.(null); + this.notify(); + } + + private snappedDelta( + session: ResizeSession, + rawDeltaTime: MediaTime, + ): MediaTime { + const { snappingEnabled, isShiftHeld, zoomLevel } = this.config; + + if (!snappingEnabled || isShiftHeld()) { + this.config.onSnapPointChange?.(null); + return rawDeltaTime; + } + + const tracks = this.config.getSceneTracks(); + const playheadTime = this.config.getCurrentPlayheadTime(); + const excludeElementIds = new Set(session.members.map((m) => m.elementId)); + + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), + () => getPlayheadSnapPoints({ playheadTime }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, + }), + ], + }); + const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); + + let closestSnapPoint: SnapPoint | null = null; + let closestSnapDistance = Infinity; + let deltaTime = rawDeltaTime; + + for (const member of session.members) { + const baseEdgeTime = + session.side === "left" + ? member.startTime + : addMediaTime({ a: member.startTime, b: member.duration }); + const snapResult = resolveTimelineSnap({ + targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }), + snapPoints, + maxSnapDistance, + }); + if ( + snapResult.snapPoint && + snapResult.snapDistance < closestSnapDistance + ) { + closestSnapDistance = snapResult.snapDistance; + closestSnapPoint = snapResult.snapPoint; + deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime }); + } + } + + this.config.onSnapPointChange?.(closestSnapPoint); + return deltaTime; + } + + private handleMouseMove({ clientX }: MouseEvent): void { + if (this.session.kind !== "active") return; + const session = this.session; + + const rawDeltaTime = mediaTime({ + ticks: Math.round( + ((clientX - session.startX) / + (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) * + TICKS_PER_SECOND, + ), + }); + const deltaTime = this.snappedDelta(session, rawDeltaTime); + const result = computeGroupResize({ + members: session.members, + side: session.side, + deltaTime, + fps: session.fps, + }); + + session.result = result; + this.config.previewElements(result.updates); + } + + private handleMouseUp(): void { + if (this.session.kind !== "active") return; + const session = this.session; + + this.config.discardPreview(); + + if ( + session.result && + hasResizeChanges({ members: session.members, result: session.result }) + ) { + this.config.commitElements(session.result.updates); + } + + this.finishSession(); + } +} diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts new file mode 100644 index 00000000..67dc6e96 --- /dev/null +++ b/apps/web/src/timeline/controllers/seek-controller.ts @@ -0,0 +1,210 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import type { FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm"; + +type SeekSource = "ruler" | "tracks"; + +interface PendingSeekSession { + kind: "pending"; + source: SeekSource; + downX: number; + downY: number; + downTime: number; +} + +type Session = { kind: "idle" } | PendingSeekSession; + +export interface SeekConfig { + zoomLevel: number; + duration: MediaTime; + isSelecting: boolean; + getPlayheadEl: () => HTMLDivElement | null; + getTrackLabelsEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + clearSelectedElements: () => void; + seek: (time: MediaTime) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: MediaTime; + }) => void; +} + +export interface SeekConfigRef { + readonly current: SeekConfig; +} + +function pixelToTime({ + clientX, + scrollContainer, + zoomLevel, + duration, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + duration: MediaTime; +}): MediaTime { + const rect = scrollContainer.getBoundingClientRect(); + const mouseX = clientX - rect.left; + const scrollLeft = scrollContainer.scrollLeft; + + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + + return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) }); +} + +function isClickGesture({ + event, + session, +}: { + event: ReactMouseEvent; + session: PendingSeekSession; +}): boolean { + const deltaX = Math.abs(event.clientX - session.downX); + const deltaY = Math.abs(event.clientY - session.downY); + const deltaTime = event.timeStamp - session.downTime; + + return deltaX <= 5 && deltaY <= 5 && deltaTime <= 500; +} + +export class SeekController { + private session: Session = { kind: "idle" }; + private readonly configRef: SeekConfigRef; + + constructor(deps: { configRef: SeekConfigRef }) { + this.configRef = deps.configRef; + this.onTracksMouseDown = this.onTracksMouseDown.bind(this); + this.onRulerMouseDown = this.onRulerMouseDown.bind(this); + this.onTracksClick = this.onTracksClick.bind(this); + this.onRulerClick = this.onRulerClick.bind(this); + } + + private get config(): SeekConfig { + return this.configRef.current; + } + + destroy(): void { + this.session = { kind: "idle" }; + } + + onTracksMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "tracks" }); + } + + onRulerMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "ruler" }); + } + + onTracksClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "tracks" }); + } + + onRulerClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "ruler" }); + } + + private beginPendingSeek({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + if (event.button !== 0) return; + + this.session = { + kind: "pending", + source, + downX: event.clientX, + downY: event.clientY, + downTime: event.timeStamp, + }; + } + + private handleClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const shouldProcess = this.shouldProcessClick({ event, source }); + this.session = { kind: "idle" }; + + if (!shouldProcess) return; + + this.config.clearSelectedElements(); + this.seekFromEvent({ event, source }); + } + + private shouldProcessClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): boolean { + if (this.session.kind !== "pending") return false; + if (this.session.source !== source) return false; + if (!isClickGesture({ event, session: this.session })) return false; + if (this.config.isSelecting) return false; + + const target = event.target as HTMLElement; + if (this.config.getPlayheadEl()?.contains(target)) return false; + + if (this.config.getTrackLabelsEl()?.contains(target)) { + this.config.clearSelectedElements(); + return false; + } + + return true; + } + + private seekFromEvent({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const scrollContainer = + source === "ruler" + ? this.config.getRulerScrollEl() + : this.config.getTracksScrollEl(); + if (!scrollContainer) return; + + const rawTime = pixelToTime({ + clientX: event.clientX, + scrollContainer, + zoomLevel: this.config.zoomLevel, + duration: this.config.duration, + }); + + const fps = this.config.getActiveProjectFps(); + const time = + fps != null + ? snapSeekMediaTime({ + time: rawTime, + duration: this.config.duration, + fps, + }) + : rawTime; + + this.config.seek(time); + this.config.setTimelineViewState({ + zoomLevel: this.config.zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }); + } +} diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts new file mode 100644 index 00000000..c5090e16 --- /dev/null +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -0,0 +1,286 @@ +import type { WheelEvent as ReactWheelEvent } from "react"; +import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { TIMELINE_ZOOM_MAX } from "@/timeline/scale"; +import { zoomToSlider } from "@/timeline/zoom-utils"; +import type { MediaTime } from "@/wasm"; + +type ZoomUpdater = number | ((prev: number) => number); + +export interface ZoomConfig { + minZoom: number; + getContainerEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getCurrentPlayheadTime: () => MediaTime; + seek: (time: MediaTime) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: MediaTime; + }) => void; +} + +export interface ZoomConfigRef { + readonly current: ZoomConfig; +} + +function clampZoom(zoomLevel: number, minZoom: number): number { + return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); +} + +export class ZoomController { + private readonly configRef: ZoomConfigRef; + private readonly subscribers = new Set<() => void>(); + + private zoomLevelValue: number; + private hasInitialized = false; + private hasRestoredPlayhead = false; + private hasRestoredScroll = false; + private previousZoom: number; + private preZoomScrollLeft = 0; + private prePlayheadAnchorScrollLeft = 0; + private isInPlayheadAnchorMode = false; + private scrollSaveTimeout: ReturnType | null = null; + + constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) { + this.configRef = deps.configRef; + + const minZoom = this.config.minZoom; + this.zoomLevelValue = + deps.initialZoom !== undefined + ? clampZoom(deps.initialZoom, minZoom) + : minZoom; + this.previousZoom = this.zoomLevelValue; + this.hasInitialized = deps.initialZoom !== undefined; + + this.setZoomLevel = this.setZoomLevel.bind(this); + this.handleWheel = this.handleWheel.bind(this); + this.saveScrollPosition = this.saveScrollPosition.bind(this); + } + + private get config(): ZoomConfig { + return this.configRef.current; + } + + get zoomLevel(): number { + return this.zoomLevelValue; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + this.scrollSaveTimeout = null; + } + } + + setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void { + const scrollElement = this.config.getTracksScrollEl(); + if (scrollElement) { + this.preZoomScrollLeft = scrollElement.scrollLeft; + } + + const nextZoomRaw = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(this.zoomLevelValue) + : zoomLevelOrUpdater; + const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + if (nextZoom === this.zoomLevelValue) return; + + this.zoomLevelValue = nextZoom; + this.notify(); + } + + handleWheel(event: ReactWheelEvent): void { + const isZoomGesture = event.ctrlKey || event.metaKey; + const isHorizontalScrollGesture = + event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + + if (isHorizontalScrollGesture) { + return; + } + + if (isZoomGesture) { + const normalizedDelta = + event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; + const cappedDelta = + Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + this.setZoomLevel((prev) => prev * zoomFactor); + } + } + + reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + if (initialZoom !== undefined && !this.hasInitialized) { + this.hasInitialized = true; + this.setZoomLevel(clampZoom(initialZoom, minZoom)); + return; + } + + if (this.zoomLevelValue < minZoom) { + this.setZoomLevel(minZoom); + } + } + + applyZoomLayout(zoomLevel: number): void { + const previousZoom = this.previousZoom; + if (previousZoom === zoomLevel) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) { + this.previousZoom = zoomLevel; + return; + } + + const currentScrollLeft = this.preZoomScrollLeft; + const playheadTime = this.config.getCurrentPlayheadTime(); + const sliderPercent = zoomToSlider({ + zoomLevel, + minZoom: this.config.minZoom, + }); + const previousSliderPercent = zoomToSlider({ + zoomLevel: previousZoom, + minZoom: this.config.minZoom, + }); + const isCrossingThresholdUp = + previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + const isCrossingThresholdDown = + previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + + const syncScroll = (scrollLeft: number) => { + scrollElement.scrollLeft = scrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = scrollLeft; + } + }; + + const clampScrollLeft = (scrollLeft: number) => { + const maxScrollLeft = + scrollElement.scrollWidth - scrollElement.clientWidth; + return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); + }; + + if (isCrossingThresholdUp) { + this.prePlayheadAnchorScrollLeft = currentScrollLeft; + this.isInPlayheadAnchorMode = true; + } + + if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { + const playheadPixelsBefore = timelineTimeToPixels({ + time: playheadTime, + zoomLevel: previousZoom, + }); + const playheadPixelsAfter = timelineTimeToPixels({ + time: playheadTime, + zoomLevel, + }); + const viewportOffset = playheadPixelsBefore - currentScrollLeft; + const nextScrollLeft = playheadPixelsAfter - viewportOffset; + syncScroll(clampScrollLeft(nextScrollLeft)); + } else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) { + syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft)); + this.isInPlayheadAnchorMode = false; + } + + this.previousZoom = zoomLevel; + + this.config.setTimelineViewState({ + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime, + }); + } + + saveScrollPosition(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + } + + this.scrollSaveTimeout = setTimeout(() => { + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + this.config.setTimelineViewState({ + zoomLevel: this.zoomLevelValue, + scrollLeft: scrollElement.scrollLeft, + playheadTime: this.config.getCurrentPlayheadTime(), + }); + }, 300); + } + + restoreInitialScrollIfNeeded( + initialScrollLeft?: number, + ): (() => void) | undefined { + if (initialScrollLeft === undefined) return; + if (this.hasRestoredScroll) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + const restoreScroll = () => { + scrollElement.scrollLeft = initialScrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = initialScrollLeft; + } + this.hasRestoredScroll = true; + this.preZoomScrollLeft = initialScrollLeft; + }; + + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + return; + } + + const observer = new ResizeObserver(() => { + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + observer.disconnect(); + } + }); + observer.observe(scrollElement); + return () => observer.disconnect(); + } + + restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void { + if (initialPlayheadTime === undefined) return; + if (this.hasRestoredPlayhead) return; + + this.hasRestoredPlayhead = true; + this.config.seek(initialPlayheadTime); + } + + bindPreventBrowserZoom(): () => void { + const preventZoom = (event: WheelEvent) => { + const isZoomKeyPressed = event.ctrlKey || event.metaKey; + const container = this.config.getContainerEl(); + const isInContainer = container?.contains(event.target as Node) ?? false; + if (isZoomKeyPressed && isInContainer) { + event.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { + passive: false, + capture: true, + }); + + return () => { + document.removeEventListener("wheel", preventZoom, { capture: true }); + }; + } + + private notify(): void { + for (const fn of this.subscribers) { + fn(); + } + } +} diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index f3c93b8d..0f28af56 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,5 +1,17 @@ -import { mediaTime, TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, mediaTimeFromSeconds, TICKS_PER_SECOND } from "@/wasm"; export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({ ticks: 5 * TICKS_PER_SECOND, }); + +export function toElementDurationTicks({ + seconds, +}: { + seconds: number | null | undefined; +}) { + if (seconds == null) { + return DEFAULT_NEW_ELEMENT_DURATION; + } + + return mediaTimeFromSeconds({ seconds }); +} diff --git a/apps/web/src/timeline/drag-data.ts b/apps/web/src/timeline/drag-data.ts deleted file mode 100644 index 1fc9ce9a..00000000 --- a/apps/web/src/timeline/drag-data.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { TimelineDragData } from "@/timeline/drag"; - -const MIME_TYPE = "application/x-timeline-drag"; -let lastDragData: TimelineDragData | null = null; - -export function setDragData({ - dataTransfer, - dragData, -}: { - dataTransfer: DataTransfer; - dragData: TimelineDragData; -}): void { - dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData)); - dataTransfer.setData("text/plain", JSON.stringify(dragData)); - lastDragData = dragData; -} - -export function getDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): TimelineDragData | null { - const data = dataTransfer.getData(MIME_TYPE); - if (data) return JSON.parse(data) as TimelineDragData; - - const textData = dataTransfer.getData("text/plain"); - if (textData) { - try { - return JSON.parse(textData) as TimelineDragData; - } catch { - return lastDragData; - } - } - - return lastDragData; -} - -export function hasDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): boolean { - return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null; -} - -export function clearDragData(): void { - lastDragData = null; -} diff --git a/apps/web/src/timeline/drag-source.ts b/apps/web/src/timeline/drag-source.ts new file mode 100644 index 00000000..503c547b --- /dev/null +++ b/apps/web/src/timeline/drag-source.ts @@ -0,0 +1,40 @@ +import type { TimelineDragData } from "@/timeline/drag"; + +const TIMELINE_DRAG_MIME = "application/x-timeline-drag"; + +/** + * Owns the state of an in-progress timeline drag session. + * + * Exists because browsers restrict `DataTransfer.getData()` to the `drop` + * event for security — during `dragover`/`dragenter` only `types` is + * readable. The drop target needs the payload (element type, target + * element types, source duration) while the pointer is hovering, so we + * keep a live copy here and hand it out via {@link getActive}. + */ +export class TimelineDragSource { + private active: TimelineDragData | null = null; + + begin({ + dataTransfer, + dragData, + }: { + dataTransfer: DataTransfer; + dragData: TimelineDragData; + }): void { + dataTransfer.setData(TIMELINE_DRAG_MIME, JSON.stringify(dragData)); + dataTransfer.effectAllowed = "copy"; + this.active = dragData; + } + + end(): void { + this.active = null; + } + + getActive(): TimelineDragData | null { + return this.active; + } + + isActive(): boolean { + return this.active !== null; + } +} diff --git a/apps/web/src/timeline/element-snap-source.ts b/apps/web/src/timeline/element-snap-source.ts index ec1e5f65..26aa86f6 100644 --- a/apps/web/src/timeline/element-snap-source.ts +++ b/apps/web/src/timeline/element-snap-source.ts @@ -1,5 +1,6 @@ import type { SceneTracks } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import { addMediaTime } from "@/wasm"; export function getElementEdgeSnapPoints({ tracks, @@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({ trackId: track.id, }, { - time: element.startTime + element.duration, + time: addMediaTime({ a: element.startTime, b: element.duration }), type: "element-end", elementId: element.id, trackId: track.id, diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index aa06a35b..8b6d196b 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -63,7 +63,7 @@ export function snapGroupEdges({ ) { closestSnapDistance = memberStartSnap.snapDistance; snappedAnchorStartTime = subMediaTime({ - a: memberStartSnap.snappedTime as MediaTime, + a: memberStartSnap.snappedTime, b: member.timeOffset, }); snapPoint = memberStartSnap.snapPoint; @@ -84,7 +84,7 @@ export function snapGroupEdges({ closestSnapDistance = memberEndSnap.snapDistance; snappedAnchorStartTime = subMediaTime({ a: subMediaTime({ - a: memberEndSnap.snappedTime as MediaTime, + a: memberEndSnap.snappedTime, b: member.duration, }), b: member.timeOffset, diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index bbe829cc..465531bf 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -1,9 +1,20 @@ -import { roundToFrame } from "opencut-wasm"; import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/retime"; -import { TICKS_PER_SECOND, roundMediaTime } from "@/wasm"; +import { + addMediaTime, + clampMediaTime, + maxMediaTime, + type MediaTime, + mediaTime, + minMediaTime, + roundFrameTicks, + roundMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import type { ComputeGroupResizeArgs, GroupResizeMember, @@ -18,31 +29,54 @@ export function computeGroupResize({ deltaTime, fps, }: ComputeGroupResizeArgs): GroupResizeResult { - const minDuration = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - const minimumDeltaTime = Math.max( - ...members.map((member) => - getMinimumAllowedDeltaTime({ + if (members.length === 0) { + return { deltaTime: ZERO_MEDIA_TIME, updates: [] }; + } + + const minDuration = mediaTime({ + ticks: Math.round((TICKS_PER_SECOND * fps.denominator) / fps.numerator), + }); + let minimumDeltaTime = getMinimumAllowedDeltaTime({ + member: members[0], + side, + minDuration, + }); + let maximumDeltaTime = getMaximumAllowedDeltaTime({ + member: members[0], + side, + minDuration, + }); + + for (const member of members.slice(1)) { + minimumDeltaTime = maxMediaTime({ + a: minimumDeltaTime, + b: getMinimumAllowedDeltaTime({ member, side, minDuration, }), - ), - ); - const maximumDeltaTime = Math.min( - ...members.map((member) => - getMaximumAllowedDeltaTime({ - member, - side, - minDuration, - }), - ), - ); + }); + const memberMaximum = getMaximumAllowedDeltaTime({ + member, + side, + minDuration, + }); + if (memberMaximum !== null) { + maximumDeltaTime = + maximumDeltaTime === null + ? memberMaximum + : minMediaTime({ a: maximumDeltaTime, b: memberMaximum }); + } + } + const clampedDeltaTime = - minimumDeltaTime > maximumDeltaTime - ? minimumDeltaTime - : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime)); + maximumDeltaTime === null + ? maxMediaTime({ a: minimumDeltaTime, b: deltaTime }) + : clampMediaTime({ + time: deltaTime, + min: minimumDeltaTime, + max: maximumDeltaTime, + }); // Snap the drag delta to a frame exactly once, then derive every patch // field from that single snapped value. This keeps the invariant @@ -51,22 +85,24 @@ export function computeGroupResize({ // so the rounding cancels by construction. Per-field rounding (the old // approach) couldn't preserve this because the individual rounds don't // compose when `sourceDuration` isn't frame-aligned. - const snappedDeltaTime = - roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime; + const snappedDeltaTime = mediaTime({ + ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }), + }); // Re-clamp after rounding. Bounds derived from other elements are // frame-aligned, so this is normally a no-op; at the source-extent limit // the bound may not be frame-aligned, and honouring the bound takes // precedence over frame alignment (you can't extend past real content). const finalDeltaTime = - minimumDeltaTime > maximumDeltaTime - ? minimumDeltaTime - : Math.min( - maximumDeltaTime, - Math.max(minimumDeltaTime, snappedDeltaTime), - ); + maximumDeltaTime === null + ? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime }) + : clampMediaTime({ + time: snappedDeltaTime, + min: minimumDeltaTime, + max: maximumDeltaTime, + }); return { - deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime, + deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime, updates: members.map((member) => buildResizeUpdate({ member, @@ -84,7 +120,7 @@ function buildResizeUpdate({ }: { member: GroupResizeMember; side: ResizeSide; - deltaTime: number; + deltaTime: MediaTime; }): GroupResizeUpdate { const sourceDelta = getSourceDeltaForClipDelta({ member, @@ -96,10 +132,13 @@ function buildResizeUpdate({ trackId: member.trackId, elementId: member.elementId, patch: { - trimStart: Math.max(0, member.trimStart + sourceDelta), + trimStart: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: member.trimStart, b: sourceDelta }), + }), trimEnd: member.trimEnd, - startTime: member.startTime + deltaTime, - duration: member.duration - deltaTime, + startTime: addMediaTime({ a: member.startTime, b: deltaTime }), + duration: subMediaTime({ a: member.duration, b: deltaTime }), }, }; } @@ -109,9 +148,12 @@ function buildResizeUpdate({ elementId: member.elementId, patch: { trimStart: member.trimStart, - trimEnd: Math.max(0, member.trimEnd - sourceDelta), + trimEnd: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: member.trimEnd, b: sourceDelta }), + }), startTime: member.startTime, - duration: member.duration + deltaTime, + duration: addMediaTime({ a: member.duration, b: deltaTime }), }, }; } @@ -123,29 +165,37 @@ function getMinimumAllowedDeltaTime({ }: { member: GroupResizeMember; side: ResizeSide; - minDuration: number; -}): number { + minDuration: MediaTime; +}): MediaTime { if (side === "right") { - return minDuration - member.duration; + return subMediaTime({ a: minDuration, b: member.duration }); } - const leftNeighborFloor = Number.isFinite(member.leftNeighborBound) - ? member.leftNeighborBound - member.startTime - : -member.startTime; + const leftNeighborFloor = + member.leftNeighborBound !== null + ? subMediaTime({ a: member.leftNeighborBound, b: member.startTime }) + : subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime }); if (member.sourceDuration == null) { return leftNeighborFloor; } - const maximumSourceExtension = - getDurationForVisibleSourceSpan({ + const maximumSourceExtension = subMediaTime({ + a: getDurationForVisibleSourceSpan({ member, - sourceSpan: - getVisibleSourceSpanForDuration({ + sourceSpan: addMediaTime({ + a: getVisibleSourceSpanForDuration({ member, duration: member.duration, - }) + member.trimStart, - }) - member.duration; - return Math.max(leftNeighborFloor, -maximumSourceExtension); + }), + b: member.trimStart, + }), + }), + b: member.duration, + }); + return maxMediaTime({ + a: leftNeighborFloor, + b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }), + }); } function getMaximumAllowedDeltaTime({ @@ -155,26 +205,38 @@ function getMaximumAllowedDeltaTime({ }: { member: GroupResizeMember; side: ResizeSide; - minDuration: number; -}): number { + minDuration: MediaTime; +}): MediaTime | null { if (side === "left") { - return member.duration - minDuration; + return subMediaTime({ a: member.duration, b: minDuration }); } - const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound) - ? member.rightNeighborBound - (member.startTime + member.duration) - : Infinity; + const rightNeighborCeiling = + member.rightNeighborBound === null + ? null + : subMediaTime({ + a: member.rightNeighborBound, + b: addMediaTime({ a: member.startTime, b: member.duration }), + }); if (member.sourceDuration == null) { return rightNeighborCeiling; } - const maximumVisibleSourceSpan = - getSourceDuration({ member }) - member.trimStart; + const maximumVisibleSourceSpan = subMediaTime({ + a: getSourceDuration({ member }), + b: member.trimStart, + }); const maximumDuration = getDurationForVisibleSourceSpan({ member, sourceSpan: maximumVisibleSourceSpan, }); - return Math.min(rightNeighborCeiling, maximumDuration - member.duration); + const sourceDurationCeiling = subMediaTime({ + a: maximumDuration, + b: member.duration, + }); + return rightNeighborCeiling === null + ? sourceDurationCeiling + : minMediaTime({ a: rightNeighborCeiling, b: sourceDurationCeiling }); } function getSourceDeltaForClipDelta({ @@ -182,8 +244,8 @@ function getSourceDeltaForClipDelta({ clipDelta, }: { member: GroupResizeMember; - clipDelta: number; -}): number { + clipDelta: MediaTime; +}): MediaTime { if (!member.retime) { return clipDelta; } @@ -206,15 +268,17 @@ function getVisibleSourceSpanForDuration({ duration, }: { member: GroupResizeMember; - duration: number; -}): number { + duration: MediaTime; +}): MediaTime { if (!member.retime) { return duration; } - return getSourceSpanAtClipTime({ - clipTime: duration, - retime: member.retime, + return roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: duration, + retime: member.retime, + }), }); } @@ -223,8 +287,8 @@ function getDurationForVisibleSourceSpan({ sourceSpan, }: { member: GroupResizeMember; - sourceSpan: number; -}): number { + sourceSpan: MediaTime; +}): MediaTime { if (!member.retime) { return sourceSpan; } @@ -237,17 +301,19 @@ function getDurationForVisibleSourceSpan({ }); } -function getSourceDuration({ member }: { member: GroupResizeMember }): number { - if (typeof member.sourceDuration === "number") { +function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime { + if (member.sourceDuration != null) { return member.sourceDuration; } - return ( - member.trimStart + - getVisibleSourceSpanForDuration({ + return addMediaTime({ + a: addMediaTime({ + a: member.trimStart, + b: getVisibleSourceSpanForDuration({ member, duration: member.duration, - }) + - member.trimEnd - ); + }), + }), + b: member.trimEnd, + }); } diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts index b07e6fe6..5c0f77f6 100644 --- a/apps/web/src/timeline/group-resize/types.ts +++ b/apps/web/src/timeline/group-resize/types.ts @@ -1,36 +1,37 @@ import type { FrameRate } from "opencut-wasm"; import type { ElementRef, RetimeConfig } from "@/timeline/types"; +import type { MediaTime } from "@/wasm"; export type ResizeSide = "left" | "right"; export interface GroupResizeMember extends ElementRef { - startTime: number; - duration: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; + startTime: MediaTime; + duration: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; retime?: RetimeConfig; - leftNeighborBound: number; - rightNeighborBound: number; + leftNeighborBound: MediaTime | null; + rightNeighborBound: MediaTime | null; } export interface GroupResizeUpdate extends ElementRef { patch: { - trimStart: number; - trimEnd: number; - startTime: number; - duration: number; + trimStart: MediaTime; + trimEnd: MediaTime; + startTime: MediaTime; + duration: MediaTime; }; } export interface GroupResizeResult { - deltaTime: number; + deltaTime: MediaTime; updates: GroupResizeUpdate[]; } export interface ComputeGroupResizeArgs { members: GroupResizeMember[]; side: ResizeSide; - deltaTime: number; + deltaTime: MediaTime; fps: FrameRate; } 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 9caca28a..26a21f85 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,49 +1,17 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, - type RefObject, -} from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; -import { - buildMoveGroup, - resolveGroupMove, - snapGroupEdges, - type GroupMoveResult, - type MoveGroup, -} from "@/timeline/group-move"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -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"; -import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; -import { generateUUID } from "@/utils/id"; -import type { SnapPoint } from "@/timeline/snapping"; import { registerCanceller } from "@/editor/cancel-interaction"; -import type { - DropTarget, - ElementRef, - ElementDragState, - SceneTracks, - TimelineElement, - TimelineTrack, -} from "@/timeline"; +import { + ElementInteractionController, + type ElementInteractionDeps, + type ElementInteractionDepsRef, +} from "@/timeline/controllers/element-interaction-controller"; +import type { SnapPoint } from "@/timeline/snapping"; interface UseElementInteractionProps { zoomLevel: number; - timelineRef: RefObject; tracksContainerRef: RefObject; tracksScrollRef: RefObject; headerRef?: RefObject; @@ -51,133 +19,8 @@ interface UseElementInteractionProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } -const MOUSE_BUTTON_RIGHT = 2; - -const initialDragState: ElementDragState = { - isDragging: false, - elementId: null, - dragElementIds: [], - dragTimeOffsets: {}, - trackId: null, - startMouseX: 0, - startMouseY: 0, - startElementTime: ZERO_MEDIA_TIME, - clickOffsetTime: ZERO_MEDIA_TIME, - currentTime: ZERO_MEDIA_TIME, - currentMouseY: 0, -}; - -interface PendingDragState { - elementId: string; - trackId: string; - selectedElements: ElementRef[]; - startMouseX: number; - startMouseY: number; - startElementTime: MediaTime; - clickOffsetTime: MediaTime; -} - -function getClickOffsetTime({ - clientX, - elementRect, - zoomLevel, -}: { - clientX: number; - elementRect: DOMRect; - zoomLevel: number; -}): MediaTime { - const clickOffsetX = clientX - elementRect.left; - const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return mediaTime({ - ticks: Math.round(seconds * TICKS_PER_SECOND), - }); -} - -function getVerticalDragDirection({ - startMouseY, - currentMouseY, -}: { - startMouseY: number; - currentMouseY: number; -}): "up" | "down" | null { - if (currentMouseY < startMouseY) return "up"; - if (currentMouseY > startMouseY) return "down"; - return null; -} - -function getDragDropTarget({ - clientX, - clientY, - elementId, - trackId, - tracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, -}: { - clientX: number; - clientY: number; - elementId: string; - trackId: string; - tracks: SceneTracks; - tracksContainerRef: RefObject; - tracksScrollRef: RefObject; - headerRef?: RefObject; - zoomLevel: number; - snappedTime: MediaTime; - verticalDragDirection?: "up" | "down" | null; -}): DropTarget | null { - const containerRect = tracksContainerRef.current?.getBoundingClientRect(); - const scrollContainer = tracksScrollRef.current; - if (!containerRect || !scrollContainer) return null; - - const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( - ({ id }) => id === trackId, - ); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === elementId, - ); - if (!movingElement) return null; - - const elementDuration = movingElement.duration; - const scrollLeft = scrollContainer.scrollLeft; - const scrollTop = scrollContainer.scrollTop; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseX = clientX - scrollContainerRect.left + scrollLeft; - const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; - - return computeDropTarget({ - elementType: movingElement.type, - mouseX, - mouseY, - tracks, - playheadTime: snappedTime, - isExternalDrop: false, - elementDuration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - startTimeOverride: snappedTime, - excludeElementId: movingElement.id, - verticalDragDirection, - }); -} - -interface StartDragParams - extends Omit< - ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" - > { - initialCurrentTime: MediaTime; - initialCurrentMouseY: number; -} - export function useElementInteraction({ zoomLevel, - timelineRef, tracksContainerRef, tracksScrollRef, headerRef, @@ -186,597 +29,65 @@ export function useElementInteraction({ }: UseElementInteractionProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const sceneTracks = editor.scenes.getActiveScene().tracks; - const { - selectedElements, - isElementSelected, - selectElement, - handleElementClick: handleSelectionClick, - } = useElementSelection(); + const selection = useElementSelection(); - const [dragState, setDragState] = - useState(initialDragState); - const [dragDropTarget, setDragDropTarget] = useState(null); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef(null); - const moveGroupRef = useRef(null); - const newTrackIdsRef = useRef([]); - const groupMoveResultRef = useRef(null); - const lastMouseXRef = useRef(0); - const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); - - const startDrag = useCallback( - ({ - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - initialCurrentTime, - initialCurrentMouseY, - }: StartDragParams) => { - setDragState({ - isDragging: true, - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - currentTime: initialCurrentTime, - currentMouseY: initialCurrentMouseY, - }); + const deps: ElementInteractionDeps = { + viewport: { + getZoomLevel: () => zoomLevel, + getTracksScrollEl: () => tracksScrollRef.current, + getTracksContainerEl: () => tracksContainerRef.current, + getHeaderEl: () => headerRef?.current ?? null, }, - [], - ); - - const endDrag = useCallback(() => { - moveGroupRef.current = null; - newTrackIdsRef.current = []; - groupMoveResultRef.current = null; - setDragState(initialDragState); - setDragDropTarget(null); - }, []); - - const cancelCurrentDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownLocationRef.current = null; - setIsPendingDrag(false); - endDrag(); - onSnapPointChange?.(null); - }, [endDrag, onSnapPointChange]); - - const resolveGroupDragMove = useCallback( - ({ - group, - snappedTime, - dropTarget, - }: { - group: MoveGroup; - snappedTime: MediaTime; - dropTarget: DropTarget | null; - }): GroupMoveResult | null => { - if (!dropTarget) { - return null; - } - - if (dropTarget.isNewTrack) { - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); - } - - const orderedTracks = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ]; - const targetTrack = orderedTracks[dropTarget.trackIndex]; - if (!targetTrack) { - return null; - } - - const existingTrackResult = resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "existingTrack", - anchorTargetTrackId: targetTrack.id, - }, - }); - if (existingTrackResult) { - return existingTrackResult; - } - - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); + input: { + isShiftHeld: () => isShiftHeldRef.current, }, - [sceneTracks], - ); + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getActiveFps: () => editor.project.getActive()?.settings.fps ?? null, + }, + selection: { + getSelected: () => selection.selectedElements, + isSelected: selection.isElementSelected, + select: selection.selectElement, + handleClick: selection.handleElementClick, + clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(), + }, + playback: { + getCurrentTime: () => editor.playback.getCurrentTime(), + }, + timeline: { + moveElements: (args) => editor.timeline.moveElements(args), + }, + snap: { + isEnabled: () => snappingEnabled, + onChange: onSnapPointChange, + }, + }; + + const depsRef = useRef(deps); + depsRef.current = deps; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ElementInteractionController({ + depsRef: depsRef as ElementInteractionDepsRef, + }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - return registerCanceller({ fn: cancelCurrentDrag }); - }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); - - const getDragSnapResult = useCallback( - ({ - frameSnappedTime, - group, - }: { - frameSnappedTime: MediaTime; - group: MoveGroup | null; - }) => { - if (!group || !snappingEnabled || isShiftHeldRef.current) { - return { snappedTime: frameSnappedTime, snapPoint: null }; - } - - const groupSnap = snapGroupEdges({ - group, - anchorStartTime: frameSnappedTime, - tracks: sceneTracks, - playheadTime: editor.playback.getCurrentTime(), - zoomLevel, - }); - - return { - snappedTime: groupSnap.snappedAnchorStartTime, - snapPoint: groupSnap.snapPoint, - }; - }, - [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - let startedDragThisEvent = false; - const timeline = timelineRef.current; - const scrollContainer = tracksScrollRef.current; - if (!timeline || !scrollContainer) return; - lastMouseXRef.current = clientX; - - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - const activeProject = editor.project.getActive(); - if (!activeProject) return; - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - 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 - ) as MediaTime; - const moveGroup = buildMoveGroup({ - anchorRef: { - trackId: pendingDragRef.current.trackId, - elementId: pendingDragRef.current.elementId, - }, - selectedElements: pendingDragRef.current.selectedElements, - tracks: sceneTracks, - }); - if (!moveGroup) { - return; - } - - moveGroupRef.current = moveGroup; - newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record = {}; - for (const member of moveGroup.members) { - dragTimeOffsets[member.elementId] = member.timeOffset; - } - const { - snappedTime: initialSnappedTime, - snapPoint: initialSnapPoint, - } = getDragSnapResult({ - frameSnappedTime: snappedTime, - group: moveGroup, - }); - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: pendingDragRef.current.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: pendingDragRef.current.elementId, - trackId: pendingDragRef.current.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime: initialSnappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - anchorDropTarget != null - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime: initialSnappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - startDrag({ - ...pendingDragRef.current, - dragElementIds: moveGroup.members.map((member) => member.elementId), - dragTimeOffsets, - initialCurrentTime: initialSnappedTime, - initialCurrentMouseY: clientY, - }); - onSnapPointChange?.(initialSnapPoint); - startedDragThisEvent = true; - pendingDragRef.current = null; - setIsPendingDrag(false); - } else { - return; - } - } - - if (startedDragThisEvent) { - return; - } - - if (dragState.elementId && dragState.trackId) { - const alreadySelected = isElementSelected({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - if (!alreadySelected) { - selectElement({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - } - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - 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 - ) as MediaTime; - - const moveGroup = moveGroupRef.current; - const { snappedTime, snapPoint } = getDragSnapResult({ - frameSnappedTime, - group: moveGroup, - }); - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snappedTime, - currentMouseY: clientY, - })); - onSnapPointChange?.(snapPoint); - - if (dragState.elementId && dragState.trackId) { - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: dragState.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: dragState.elementId, - trackId: dragState.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - moveGroup && anchorDropTarget - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.startMouseY, - dragState.trackId, - zoomLevel, - isElementSelected, - selectElement, - editor.project, - timelineRef, - tracksScrollRef, - tracksContainerRef, - headerRef, - isPendingDrag, - startDrag, - getDragSnapResult, - resolveGroupDragMove, - sceneTracks, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); - if ( - deltaX <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - endDrag(); - onSnapPointChange?.(null); - return; - } - } - - const moveGroup = moveGroupRef.current; - if (!moveGroup) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const groupMoveResult = groupMoveResultRef.current; - if (!groupMoveResult) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const didMove = groupMoveResult.moves.some((move) => { - const currentMember = moveGroup.members.find( - (member) => member.elementId === move.elementId, - ); - const originalStartTime = addMediaTime({ - a: dragState.startElementTime, - b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME, - }); - return ( - currentMember?.trackId !== move.targetTrackId || - originalStartTime !== move.newStartTime - ); - }); - if (!didMove && groupMoveResult.createTracks.length === 0) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - editor.timeline.moveElements({ - moves: groupMoveResult.moves, - createTracks: groupMoveResult.createTracks, - }); - endDrag(); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.elementId, - dragState.startElementTime, - dragState.trackId, - endDrag, - onSnapPointChange, - editor.timeline, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag, onSnapPointChange]); - - const handleElementMouseDown = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: false, - }); - } - return; - } - - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - - if (isMultiSelect) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: true, - }); - } - - const clickOffsetTime = getClickOffsetTime({ - clientX: event.clientX, - elementRect: event.currentTarget.getBoundingClientRect(), - zoomLevel, - }); - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const pendingSelectedElements = isElementSelected(elementRef) - ? selectedElements - : [elementRef]; - pendingDragRef.current = { - elementId: element.id, - trackId: track.id, - selectedElements: pendingSelectedElements, - startMouseX: event.clientX, - startMouseY: event.clientY, - startElementTime: element.startTime, - clickOffsetTime, - }; - setIsPendingDrag(true); - }, - [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], - ); - - const handleElementClick = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - event.stopPropagation(); - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - return; - } - } - - // modifier keys already handled in mousedown - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected || selectedElements.length > 1) { - selectElement({ trackId: track.id, elementId: element.id }); - return; - } - - editor.selection.clearKeyframeSelection(); - }, - [editor.selection, isElementSelected, selectElement, selectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, + dragView: controller.view, + handleElementMouseDown: controller.onElementMouseDown, + handleElementClick: controller.onElementClick, }; } 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 afead4e9..ea03cb5c 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,48 +1,16 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { getKeyframeById } from "@/animation"; 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 { - 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"; -import type { SelectedKeyframeRef } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import type { Command } from "@/commands/base-command"; import { registerCanceller } from "@/editor/cancel-interaction"; -export interface KeyframeDragState { - isDragging: boolean; - draggingKeyframeIds: Set; - deltaTime: MediaTime; -} +import { + KeyframeDragController, + type KeyframeDragConfig, + type KeyframeDragState, +} from "@/timeline/controllers/keyframe-drag-controller"; +import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; -const initialDragState: KeyframeDragState = { - isDragging: false, - draggingKeyframeIds: new Set(), - deltaTime: ZERO_MEDIA_TIME, -}; - -interface PendingKeyframeDrag { - keyframeRefs: SelectedKeyframeRef[]; - startMouseX: number; -} +export type { KeyframeDragState }; export function useKeyframeDrag({ zoomLevel, @@ -62,286 +30,44 @@ export function useKeyframeDrag({ selectKeyframeRange, } = useKeyframeSelection(); - const [dragState, setDragState] = - useState(initialDragState); - const [isPendingDrag, setIsPendingDrag] = useState(false); - - const pendingDragRef = useRef(null); - const mouseDownXRef = useRef(null); - - const activeProject = editor.project.getActive(); - const fps = activeProject.settings.fps; - - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - - const endDrag = useCallback(() => { - setDragState(initialDragState); - }, []); - - const cancelDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownXRef.current = null; - setIsPendingDrag(false); - endDrag(); - }, [endDrag]); - - const commitDrag = useCallback( - ({ - keyframeRefs, - deltaTime, - }: { - keyframeRefs: SelectedKeyframeRef[]; - deltaTime: MediaTime; - }) => { - const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - if (!keyframe) return []; - 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, - elementId: keyframeRef.elementId, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - nextTime, - }), - ]; - }); - - if (commands.length === 1) { - editor.command.execute({ command: commands[0] }); - } else if (commands.length > 1) { - editor.command.execute({ command: new BatchCommand(commands) }); - } - }, - [editor.command, element], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelDrag }); - }, [dragState.isDragging, isPendingDrag, cancelDrag]); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX }: MouseEvent) => { - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; - - const pending = pendingDragRef.current; - pendingDragRef.current = null; - setIsPendingDrag(false); - setDragState({ - isDragging: true, - draggingKeyframeIds: new Set( - pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), - ), - deltaTime: ZERO_MEDIA_TIME, - }); - return; - } - - if (!dragState.isDragging) return; - - const startX = mouseDownXRef.current ?? clientX; - 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 })); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - const draggingRefs = selectedKeyframes.filter( - (keyframe) => - keyframe.elementId === element.id && - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - - if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { - commitDrag({ - keyframeRefs: draggingRefs, - deltaTime: dragState.deltaTime, - }); - } - - endDrag(); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.draggingKeyframeIds, - dragState.deltaTime, + const config: KeyframeDragConfig = { + zoomLevel, + element, + displayedStartTime, + getFps: () => editor.project.getActive()?.settings.fps ?? null, selectedKeyframes, - element.id, - commitDrag, - endDrag, - ]); + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + executeCommand: (command) => editor.command.execute({ command }), + seek: ({ time }) => editor.playback.seek({ time }), + getTotalDuration: () => editor.timeline.getTotalDuration(), + }; + + const configRef = useRef(config); + configRef.current = config; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new KeyframeDragController({ configRef }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!isPendingDrag) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag]); - - const handleKeyframeMouseDown = useCallback( - ({ - event, - keyframes, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.preventDefault(); - event.stopPropagation(); - - mouseDownXRef.current = event.clientX; - - const anySelected = keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - - const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; - if (!anySelected && !isModifierKey) { - setKeyframeSelection({ keyframes }); - } - - const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; - - pendingDragRef.current = { - keyframeRefs: keyframeRefsToTrack, - startMouseX: event.clientX, - }; - setIsPendingDrag(true); - }, - [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], - ); - - const handleKeyframeClick = useCallback( - ({ - event, - keyframes, - orderedKeyframes, - indicatorTime, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: MediaTime; - }) => { - event.stopPropagation(); - - const wasDrag = - mouseDownXRef.current !== null && - Math.abs(event.clientX - mouseDownXRef.current) > - TIMELINE_DRAG_THRESHOLD_PX; - mouseDownXRef.current = null; - - if (wasDrag) return; - - const duration = editor.timeline.getTotalDuration(); - const absoluteIndicatorTime = addMediaTime({ - a: displayedStartTime, - b: indicatorTime, - }); - const seekTime = ( - snappedSeekTime({ - time: absoluteIndicatorTime, - duration, - rate: fps, - }) ?? absoluteIndicatorTime - ) as MediaTime; - editor.playback.seek({ time: seekTime }); - - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } - - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }, - [ - toggleKeyframeSelection, - selectKeyframeRange, - editor, - displayedStartTime, - fps, - ], - ); - - const getVisualOffsetPx = useCallback( - ({ - indicatorTime, - indicatorOffsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }): number => { - if (!isBeingDragged) return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min(element.duration, indicatorTime + dragState.deltaTime), - ); - return ( - timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, - zoomLevel, - }) - elementLeft - ); - }, - [dragState.deltaTime, element.duration, zoomLevel], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - keyframeDragState: dragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, + keyframeDragState: controller.keyframeDragState, + handleKeyframeMouseDown: controller.onKeyframeMouseDown, + handleKeyframeClick: controller.onKeyframeClick, + getVisualOffsetPx: controller.getVisualOffsetPx, }; } 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 5a892ccf..4ec2b80b 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,30 +1,9 @@ -import { useState, useCallback, type RefObject } from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; -import { processMediaAssets } from "@/media/processing"; -import { toast } from "sonner"; -import { showMediaUploadToast } from "@/media/upload-toast"; -import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { roundToFrame } from "opencut-wasm"; import { - buildTextElement, - buildGraphicElement, - buildStickerElement, - buildElementFromMedia, - buildEffectElement, -} from "@/timeline/element-utils"; -import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; -import { BatchCommand } from "@/commands"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getDragData, hasDragData } from "@/timeline/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/timeline"; -import type { - MediaDragData, - GraphicDragData, - StickerDragData, - EffectDragData, -} from "@/timeline/drag"; + DragDropController, + type DragDropConfig, +} from "@/timeline/controllers/drag-drop-controller"; interface UseTimelineDragDropProps { containerRef: RefObject; @@ -40,609 +19,47 @@ export function useTimelineDragDrop({ zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState(null); - const [dragElementType, setElementType] = useState(null); - const getSnappedTime = useCallback( - ({ time }: { time: MediaTime }) => { - const projectFps = editor.project.getActive().settings.fps; - return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime; - }, - [editor], - ); + const config: DragDropConfig = { + zoomLevel, + getContainerEl: () => containerRef.current, + getHeaderEl: () => headerRef?.current ?? null, + getTracksScrollEl: () => tracksScrollRef?.current ?? null, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + getActiveProjectId: () => + editor.project.getActiveOrNull()?.metadata.id ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + dragSource: editor.timeline.dragSource, + addMediaAsset: (args) => editor.media.addMediaAsset(args), + executeCommand: (command) => editor.command.execute({ command }), + insertElement: (args) => editor.timeline.insertElement(args), + addClipEffect: (args) => editor.timeline.addClipEffect(args), + }; - const getElementType = useCallback( - ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { - const dragData = getDragData({ dataTransfer }); - if (!dragData) return null; + const configRef = useRef(config); + configRef.current = config; - if (dragData.type === "text") return "text"; - if (dragData.type === "graphic") return "graphic"; - if (dragData.type === "sticker") return "sticker"; - if (dragData.type === "effect") return "effect"; - if (dragData.type === "media") { - return dragData.mediaType; - } - return null; - }, - [], - ); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new DragDropController({ configRef }); + } + const controller = controllerRef.current; - const getElementDuration = useCallback( - ({ - elementType, - mediaId, - }: { - elementType: ElementType; - mediaId?: string; - }): MediaTime => { - if ( - elementType === "text" || - elementType === "graphic" || - elementType === "sticker" || - elementType === "effect" - ) { - return DEFAULT_NEW_ELEMENT_DURATION; - } - if (mediaId) { - const mediaAssets = editor.media.getAssets(); - const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration != null - ? mediaTimeFromSeconds({ seconds: media.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - } - return DEFAULT_NEW_ELEMENT_DURATION; - }, - [editor], - ); - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.types.includes("Files"); - if (!hasAsset && !hasFiles) return; - setIsDragOver(true); - }, []); - - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const hasFiles = e.dataTransfer.types.includes("Files"); - const isExternal = - hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); - - const elementType = getElementType({ dataTransfer: e.dataTransfer }); - - if (!elementType && hasFiles && isExternal) { - setDropTarget(null); - setElementType(null); - return; - } - - if (!elementType) return; - - setElementType(elementType); - - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - const duration = getElementDuration({ - elementType, - mediaId: dragData?.type === "media" ? dragData.id : undefined, - }); - - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; - - const targetElementTypes = - dragData?.type === "effect" - ? (dragData as EffectDragData).targetElementTypes - : dragData?.type === "media" - ? (dragData as MediaDragData).targetElementTypes - : undefined; - - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const target = computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: isExternal, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - targetElementTypes, - }); - - target.xPosition = getSnappedTime({ time: target.xPosition }); - - setDropTarget(target); - e.dataTransfer.dropEffect = "copy"; - }, - [ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, - getElementType, - getElementDuration, - getSnappedTime, - editor, - ], - ); - - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - const { clientX, clientY } = e; - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - } - } - }, - [containerRef], - ); - - const executeTextDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: { name?: string; content?: string }; - }) => { - const element = buildTextElement({ - raw: { - name: dragData.name ?? "", - content: dragData.content ?? "", - }, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("text", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeStickerDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: StickerDragData; - }) => { - const element = buildStickerElement({ - stickerId: dragData.stickerId, - name: dragData.name, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeGraphicDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: GraphicDragData; - }) => { - const element = buildGraphicElement({ - definitionId: dragData.definitionId, - name: dragData.name, - startTime: target.xPosition, - params: dragData.params, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeMediaDrop = useCallback( - ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { - if (target.targetElement) { - toast.info("Replace media source is coming soon!"); - return; - } - - const mediaAssets = editor.media.getAssets(); - const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); - if (!mediaAsset) return; - - const trackType: TrackType = - dragData.mediaType === "audio" ? "audio" : "video"; - - const duration = - mediaAsset.duration != null - ? mediaTimeFromSeconds({ seconds: mediaAsset.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: mediaAsset.id, - mediaType: mediaAsset.type, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeEffectDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: EffectDragData; - }) => { - if (target.targetElement) { - editor.timeline.addClipEffect({ - trackId: target.targetElement.trackId, - elementId: target.targetElement.elementId, - effectType: dragData.effectType, - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const effectTrack = tracks.find((t) => t.type === "effect"); - let trackId: string; - - if (effectTrack) { - trackId = effectTrack.id; - } else if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element: buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }), - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } else { - const track = tracks[target.trackIndex]; - if (!track || track.type !== "effect") return; - trackId = track.id; - } - - const element = buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }); - - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - }, - [editor], - ); - - const executeFileDrop = useCallback( - async ({ - files, - mouseX, - mouseY, - }: { - files: File[]; - mouseX: number; - mouseY: number; - }) => { - const activeProject = editor.project.getActiveOrNull(); - if (!activeProject) return; - - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; - - for (const asset of processedAssets) { - const createdAsset = await editor.media.addMediaAsset({ - projectId, - asset, - }); - if (!createdAsset) continue; - - const duration = - createdAsset.duration != null - ? mediaTimeFromSeconds({ seconds: createdAsset.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const reuseMainTrackId = - createdAsset.type !== "audio" && - sceneTracks.overlay.length === 0 && - sceneTracks.audio.length === 0 && - sceneTracks.main.elements.length === 0 - ? sceneTracks.main.id - : null; - const dropTarget = reuseMainTrackId - ? null - : computeDropTarget({ - elementType: createdAsset.type, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = - createdAsset.type === "audio" ? "audio" : "video"; - - const startTime = dropTarget?.xPosition ?? currentTime; - const element = buildElementFromMedia({ - mediaId: createdAsset.id, - mediaType: createdAsset.type, - name: createdAsset.name, - duration, - startTime, - }); - - if (reuseMainTrackId) { - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: reuseMainTrackId }, - }), - }); - } else { - if (!dropTarget) continue; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - editor.command.execute({ - command: new BatchCommand([ - addTrackCmd, - new InsertElementCommand({ - element, - placement: { - mode: "explicit", - trackId: addTrackCmd.getTrackId(), - }, - }), - ]), - }); - } else { - const trackId = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ][dropTarget.trackIndex]?.id; - if (!trackId) continue; - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }), - }); - } - } - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - }, - [editor, zoomLevel], - ); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.files?.length > 0; - - if (!hasAsset && !hasFiles) return; - - const currentTarget = dropTarget; - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - - try { - if (hasAsset) { - if (!currentTarget) return; - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - if (!dragData) return; - - if (dragData.type === "text") { - executeTextDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "graphic") { - executeGraphicDrop({ - target: currentTarget, - dragData: dragData as GraphicDragData, - }); - } else if (dragData.type === "sticker") { - executeStickerDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "effect") { - executeEffectDrop({ - target: currentTarget, - dragData: dragData as EffectDragData, - }); - } else { - executeMediaDrop({ target: currentTarget, dragData }); - } - } else if (hasFiles) { - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = - e.clientY - referenceRect.top + scrollTop - headerHeight; - await executeFileDrop({ - files: Array.from(e.dataTransfer.files), - mouseX, - mouseY, - }); - } - } catch (err) { - console.error("Failed to process drop:", err); - } - }, - [ - dropTarget, - executeTextDrop, - executeGraphicDrop, - executeStickerDrop, - executeMediaDrop, - executeEffectDrop, - executeFileDrop, - containerRef, - headerRef, - tracksScrollRef, - ], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + useEffect(() => () => controller.destroy(), [controller]); return { - isDragOver, - dropTarget, - dragElementType, + isDragOver: controller.isDragOver, + dropTarget: controller.dropTarget, + dragElementType: controller.dragElementType, dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, + onDragEnter: controller.onDragEnter, + onDragOver: controller.onDragOver, + onDragLeave: controller.onDragLeave, + onDrop: controller.onDrop, }, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 0ec39f69..15ef3575 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,23 +1,13 @@ -import { snappedSeekTime } from "opencut-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 { useEffect, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { timelineTimeToPixels } from "@/timeline"; import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, -} from "@/timeline/snapping"; -import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; -import { - getCenteredLineLeft, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; + PlayheadController, + type PlayheadConfig, +} from "@/timeline/controllers/playhead-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelinePlayheadProps { zoomLevel: number; @@ -35,272 +25,81 @@ export function useTimelinePlayhead({ playheadRef, }: UseTimelinePlayheadProps) { const editor = useEditor(); - const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const activeProject = editor.project.getActive(); - const duration = editor.timeline.getTotalDuration(); const isShiftHeldRef = useShiftKey(); + // isScrubbing drives useEdgeAutoScroll — the controller sets it on the editor, + // so this reactive read naturally reflects whether scrubbing is active. + const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const zoomLevelRef = useRef(zoomLevel); - const durationRef = useRef(duration); - const isScrubbingRef = useRef(isScrubbing); - const isPlayingRef = useRef(false); + const config: PlayheadConfig = { + zoomLevel, + duration: editor.timeline.getTotalDuration(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + isShiftHeld: () => isShiftHeldRef.current, + getIsPlaying: () => editor.playback.getIsPlaying(), + getRulerEl: () => rulerRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getPlayheadEl: () => playheadRef?.current ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getSceneBookmarks: () => editor.scenes.getActiveScene()?.bookmarks ?? [], + seek: (time) => editor.playback.seek({ time }), + setScrubbing: (scrubbing) => + editor.playback.setScrubbing({ isScrubbing: scrubbing }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), + }; + const configRef = useRef(config); + configRef.current = config; - useEffect(() => { - zoomLevelRef.current = zoomLevel; - durationRef.current = duration; - isScrubbingRef.current = isScrubbing; - isPlayingRef.current = editor.playback.getIsPlaying(); - }, [zoomLevel, duration, isScrubbing, editor.playback]); - - const seek = useCallback( - ({ time }: { time: MediaTime }) => editor.playback.seek({ time }), - [editor.playback], - ); - - const scrubTimeRef = useRef(null); - const isDraggingRulerRef = useRef(false); - const hasDraggedRulerRef = useRef(false); - const lastMouseXRef = useRef(0); - - const handleScrub = useCallback( - ({ - event, - snappingEnabled = true, - }: { - event: MouseEvent | React.MouseEvent; - snappingEnabled?: boolean; - }) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rulerRect = ruler.getBoundingClientRect(); - const relativeMouseX = event.clientX - rulerRect.left; - - const timelineContentWidth = timelineTimeToPixels({ - time: duration, - zoomLevel, - }); - - const clampedMouseX = Math.max( - 0, - Math.min(timelineContentWidth, relativeMouseX), - ); - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = mediaTime({ - ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), - }); - - const rate = activeProject.settings.fps; - const frameTime = ( - snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime - ) as MediaTime; - - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - const time: MediaTime = (() => { - if (!shouldSnap) return frameTime; - const tracks = editor.scenes.getActiveScene().tracks; - const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks }), - () => getBookmarkSnapPoints({ bookmarks }), - () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), - ], - }); - const snapResult = resolveTimelineSnap({ - targetTime: frameTime, - snapPoints, - maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), - }); - return snapResult.snapPoint - ? (snapResult.snappedTime as MediaTime) - : frameTime; - })(); - - scrubTimeRef.current = time; - seek({ time }); - - lastMouseXRef.current = event.clientX; - }, - [ - duration, - zoomLevel, - seek, - rulerRef, - activeProject.settings.fps, - isShiftHeldRef, - editor.scenes, - ], - ); - - const handlePlayheadMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event }); - }, - [handleScrub, editor.playback], - ); - - const handleRulerMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - if (event.button !== 0) return; - if (playheadRef?.current?.contains(event.target as Node)) return; - - event.preventDefault(); - isDraggingRulerRef.current = true; - hasDraggedRulerRef.current = false; - - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], - ); - - const handlePlayheadMouseDownEvent = useCallback( - (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), - [handlePlayheadMouseDown], - ); - - const handleRulerMouseDownEvent = useCallback( - (event: React.MouseEvent) => handleRulerMouseDown({ event }), - [handleRulerMouseDown], - ); - - useEdgeAutoScroll({ - isActive: isScrubbing, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), - }); - - useEffect(() => { - if (!isScrubbing) return; - - const handleMouseMove = ({ event }: { event: MouseEvent }) => { - handleScrub({ event }); - if (isDraggingRulerRef.current) { - hasDraggedRulerRef.current = true; - } - }; - - const handleMouseUp = ({ event }: { event: MouseEvent }) => { - editor.playback.setScrubbing({ isScrubbing: false }); - const finalTime = scrubTimeRef.current; - if (finalTime !== null) { - seek({ time: finalTime }); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: finalTime, - }, - }); - } - scrubTimeRef.current = null; - - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - if (!hasDraggedRulerRef.current) { - handleScrub({ event, snappingEnabled: false }); - } - hasDraggedRulerRef.current = false; - } - }; - - const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); - const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); - - const updatePlayheadLeft = useCallback( - (time: MediaTime) => { - const playheadEl = playheadRef?.current; - if (!playheadEl) return; - const centerPosition = timelineTimeToSnappedPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); - const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; - playheadEl.style.left = `${leftPosition - scrollLeft}px`; - }, - [playheadRef, rulerScrollRef], - ); + const ctrlRef = useRef(null); + if (!ctrlRef.current) { + ctrlRef.current = new PlayheadController({ configRef }); + } + const ctrl = ctrlRef.current; + // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { const scrollEl = rulerScrollRef.current; if (!scrollEl) return; + const handler = () => + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + scrollEl.addEventListener("scroll", handler, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handler); + }, [ctrl, editor.playback, rulerScrollRef]); - const handleScroll = () => { - updatePlayheadLeft(editor.playback.getCurrentTime()); - }; - - scrollEl.addEventListener("scroll", handleScroll, { passive: true }); - return () => scrollEl.removeEventListener("scroll", handleScroll); - }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); - + // Playback events → update playhead position and auto-scroll during playback. useEffect(() => { - const handlePlaybackTime = (time: MediaTime) => { - updatePlayheadLeft(time); - - if (!isPlayingRef.current || isScrubbingRef.current) return; - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPixels = timelineTimeToPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; - - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }; - const handlePlaybackUpdate = (e: Event) => { - handlePlaybackTime((e as CustomEvent<{ time: MediaTime }>).detail.time); - }; - - const initialTime = editor.playback.getCurrentTime(); - handlePlaybackTime(initialTime); - - window.addEventListener("playback-update", handlePlaybackUpdate); - window.addEventListener("playback-seek", handlePlaybackUpdate); + const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time); + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + const unsubscribeUpdate = editor.playback.onUpdate(handler); + const unsubscribeSeek = editor.playback.onSeek(handler); return () => { - window.removeEventListener("playback-update", handlePlaybackUpdate); - window.removeEventListener("playback-seek", handlePlaybackUpdate); + unsubscribeUpdate(); + unsubscribeSeek(); }; - }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); + }, [ctrl, editor.playback]); + + useEdgeAutoScroll({ + isActive: isScrubbing, + getMouseClientX: () => ctrl.getLastMouseClientX(), + rulerScrollRef, + tracksScrollRef, + contentWidth: timelineTimeToPixels({ + time: editor.timeline.getTotalDuration(), + zoomLevel, + }), + }); + + useEffect(() => () => ctrl.destroy(), [ctrl]); return { - handlePlayheadMouseDown: handlePlayheadMouseDownEvent, - handleRulerMouseDown: handleRulerMouseDownEvent, + handlePlayheadMouseDown: ctrl.onPlayheadMouseDown, + handleRulerMouseDown: ctrl.onRulerMouseDown, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index accda4a1..3579bd75 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,332 +1,82 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { EditorCore } from "@/core"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; -import { - computeGroupResize, - type GroupResizeMember, - type GroupResizeResult, - type ResizeSide, -} from "@/timeline/group-resize"; -import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, - type SnapPoint, -} from "@/timeline/snapping"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; -import { isRetimableElement } from "@/timeline"; import { registerCanceller } from "@/editor/cancel-interaction"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + ResizeController, + type ResizeConfig, +} from "@/timeline/controllers/resize-controller"; +import type { ResizeSide } from "@/timeline/group-resize"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineElement } from "@/timeline"; -interface ResizeInteractionState { - side: ResizeSide; - startX: number; - members: GroupResizeMember[]; +export type { ResizeSide }; + +interface UseTimelineResizeProps { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } export function useTimelineResize({ zoomLevel, onSnapPointChange, -}: { - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}) { +}: UseTimelineResizeProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const { selectedElements } = useElementSelection(); - const [resizeState, setResizeState] = useState( - null, - ); - const latestResultRef = useRef(null); - const cancelResize = useCallback(() => { - editor.timeline.discardPreview(); - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }, [editor.timeline, onSnapPointChange]); - - useEffect(() => { - if (!resizeState) { - return; - } - - return registerCanceller({ fn: cancelResize }); - }, [resizeState, cancelResize]); - - const handleResizeStart = useCallback( - ({ - event, - element, - track, - side, - }: { - event: React.MouseEvent; - element: TimelineElement; - track: TimelineTrack; - side: ResizeSide; - }) => { - event.stopPropagation(); - event.preventDefault(); - - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const activeSelection = selectedElements.some( - (selectedElement) => - selectedElement.trackId === track.id && - selectedElement.elementId === element.id, - ) - ? selectedElements - : [elementRef]; - const members = buildResizeMembers({ - tracks: editor.scenes.getActiveScene().tracks, - selectedElements: activeSelection, - }); - if (members.length === 0) { - return; - } - - editor.timeline.discardPreview(); - latestResultRef.current = null; - setResizeState({ - side, - startX: event.clientX, - members, - }); - }, - [selectedElements, editor], - ); - - useEffect(() => { - if (!resizeState) { - return; - } - - const handleMouseMove = ({ clientX }: MouseEvent) => { - const deltaX = clientX - resizeState.startX; - const rawDeltaTime = Math.round( - (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * - TICKS_PER_SECOND, - ); - const snappedDeltaTime = getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap: snappingEnabled && !isShiftHeldRef.current, - onSnapPointChange, - }); - const fps = editor.project.getActive().settings.fps; - const result = computeGroupResize({ - members: resizeState.members, - side: resizeState.side, - deltaTime: snappedDeltaTime.deltaTime, - fps, - }); - - latestResultRef.current = result; + const config: ResizeConfig = { + zoomLevel, + snappingEnabled, + isShiftHeld: () => isShiftHeldRef.current, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + selectedElements, + discardPreview: () => editor.timeline.discardPreview(), + previewElements: (updates) => editor.timeline.previewElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ + updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, updates: patch as Partial, })), - }); - }; - - const handleMouseUp = () => { - const result = latestResultRef.current; - editor.timeline.discardPreview(); - if ( - result && - hasResizeChanges({ members: resizeState.members, result }) - ) { - editor.timeline.updateElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ - trackId, - elementId, - patch: patch as Partial, - })), - }); - } - - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - resizeState, - zoomLevel, - snappingEnabled, - isShiftHeldRef, - editor, + }), + commitElements: (updates) => + editor.timeline.updateElements({ + updates: updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + patch: patch as Partial, + })), + }), onSnapPointChange, - ]); + }; + + const configRef = useRef(config); + configRef.current = config; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ResizeController({ configRef }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + + useEffect(() => { + if (!controller.isResizing) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isResizing, controller]); + + useEffect(() => () => controller.destroy(), [controller]); return { - isResizing: resizeState !== null, - handleResizeStart, + isResizing: controller.isResizing, + handleResizeStart: controller.onResizeStart, }; } - -function buildResizeMembers({ - tracks, - selectedElements, -}: { - tracks: SceneTracks; - selectedElements: Array<{ trackId: string; elementId: string }>; -}): GroupResizeMember[] { - const selectedElementIds = new Set( - selectedElements.map((selectedElement) => selectedElement.elementId), - ); - const trackMap = new Map( - [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ - track.id, - track, - ]), - ); - - return selectedElements.flatMap(({ trackId, elementId }) => { - const track = trackMap.get(trackId); - const element = track?.elements.find( - (trackElement) => trackElement.id === elementId, - ); - if (!track || !element) { - return []; - } - - const otherElements = track.elements.filter( - (trackElement) => !selectedElementIds.has(trackElement.id), - ); - const leftNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime + trackElement.duration <= element.startTime, - ) - .reduce((bound, trackElement) => { - return Math.max(bound, trackElement.startTime + trackElement.duration); - }, -Infinity); - const rightNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime >= element.startTime + element.duration, - ) - .reduce((bound, trackElement) => { - return Math.min(bound, trackElement.startTime); - }, Infinity); - - return [ - { - trackId, - elementId, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - sourceDuration: element.sourceDuration, - retime: isRetimableElement(element) ? element.retime : undefined, - leftNeighborBound, - rightNeighborBound, - }, - ]; - }); -} - -function getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap, - onSnapPointChange, -}: { - editor: EditorCore; - resizeState: ResizeInteractionState; - rawDeltaTime: number; - zoomLevel: number; - shouldSnap: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}): { deltaTime: number } { - if (!shouldSnap) { - onSnapPointChange?.(null); - return { deltaTime: rawDeltaTime }; - } - - const tracks = editor.scenes.getActiveScene().tracks; - const playheadTime = editor.playback.getCurrentTime(); - const excludeElementIds = new Set( - resizeState.members.map((member) => member.elementId), - ); - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), - () => getPlayheadSnapPoints({ playheadTime }), - () => - getAnimationKeyframeSnapPointsForTimeline({ - tracks, - excludeElementIds, - }), - ], - }); - const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); - let closestSnapPoint: SnapPoint | null = null; - let closestSnapDistance = Infinity; - let deltaTime = rawDeltaTime; - - for (const member of resizeState.members) { - const baseEdgeTime = - resizeState.side === "left" - ? member.startTime - : member.startTime + member.duration; - const snapResult = resolveTimelineSnap({ - targetTime: baseEdgeTime + rawDeltaTime, - snapPoints, - maxSnapDistance, - }); - if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { - closestSnapDistance = snapResult.snapDistance; - closestSnapPoint = snapResult.snapPoint; - deltaTime = snapResult.snappedTime - baseEdgeTime; - } - } - - onSnapPointChange?.(closestSnapPoint); - return { deltaTime }; -} - -function hasResizeChanges({ - members, - result, -}: { - members: GroupResizeMember[]; - result: GroupResizeResult; -}): boolean { - return result.updates.some((update) => { - const member = members.find( - (candidateMember) => candidateMember.elementId === update.elementId, - ); - return ( - member?.trimStart !== update.patch.trimStart || - member?.trimEnd !== update.patch.trimEnd || - member?.startTime !== update.patch.startTime || - member?.duration !== update.patch.duration - ); - }); -} diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 88a8268d..bf033dae 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,9 +1,10 @@ -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 { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { + SeekController, + type SeekConfig, +} from "@/timeline/controllers/seek-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelineSeekProps { playheadRef: RefObject; @@ -17,44 +18,6 @@ interface UseTimelineSeekProps { seek: (time: MediaTime) => void; } -function resetMouseTracking({ - mouseTrackingRef, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; -}) { - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; -} - -function setMouseTracking({ - mouseTrackingRef, - event, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; - event: React.MouseEvent; -}) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: event.clientX, - downY: event.clientY, - downTime: event.timeStamp, - }; -} - export function useTimelineSeek({ playheadRef, trackLabelsRef, @@ -67,133 +30,42 @@ export function useTimelineSeek({ seek, }: UseTimelineSeekProps) { const editor = useEditor(); - const activeProject = editor.project.getActive(); - - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const shouldProcessTimelineClick = useCallback( - ({ event }: { event: React.MouseEvent }) => { - const target = event.target as HTMLElement; - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - const deltaX = Math.abs(event.clientX - downX); - const deltaY = Math.abs(event.clientY - downY); - const deltaTime = event.timeStamp - downTime; - const isPlayhead = !!playheadRef.current?.contains(target); - const isTrackLabels = !!trackLabelsRef.current?.contains(target); - const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; - - if (!isMouseDown) return false; - if (shouldBlockForDrag) return false; - if (isSelecting) return false; - if (isPlayhead) return false; - if (isTrackLabels) { - clearSelectedElements(); - return false; - } - - return true; - }, - [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], - ); - - const handleTimelineSeek = useCallback( - ({ - event, - source, - }: { - event: React.MouseEvent; - source: "ruler" | "tracks"; - }) => { - const scrollContainer = - source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; - - if (!scrollContainer) return; - - const rect = scrollContainer.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const scrollLeft = scrollContainer.scrollLeft; - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = mediaTime({ - ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), - }); - - const rate = activeProject?.settings.fps; - const time = rate - ? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime) - : rawTime; - seek(time); + const config: SeekConfig = { + zoomLevel, + duration, + isSelecting, + getPlayheadEl: () => playheadRef.current, + getTrackLabelsEl: () => trackLabelsRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + clearSelectedElements, + seek, + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => editor.project.setTimelineViewState({ viewState: { zoomLevel, - scrollLeft: scrollContainer.scrollLeft, - playheadTime: time, + scrollLeft, + playheadTime, }, - }); - }, - [ - duration, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - seek, - editor, - activeProject?.settings.fps.numerator, - activeProject?.settings.fps.denominator, - ], - ); + }), + }; - const handleTracksClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); + const configRef = useRef(config); + configRef.current = config; - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "tracks" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new SeekController({ configRef }); + } + const controller = controllerRef.current; - const handleRulerClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); - - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "ruler" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - handleTracksMouseDown, - handleTracksClick, - handleRulerMouseDown, - handleRulerClick, + handleTracksMouseDown: controller.onTracksMouseDown, + handleTracksClick: controller.onTracksClick, + handleRulerMouseDown: controller.onRulerMouseDown, + handleRulerClick: controller.onRulerClick, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 0f75face..3020504f 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -1,17 +1,17 @@ import { type WheelEvent as ReactWheelEvent, type RefObject, - useCallback, useEffect, useLayoutEffect, + useReducer, useRef, - useState, } from "react"; -import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; -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 { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; +import { + ZoomController, + type ZoomConfig, +} from "@/timeline/controllers/zoom-controller"; import type { MediaTime } from "@/wasm"; interface UseTimelineZoomProps { @@ -41,252 +41,59 @@ export function useTimelineZoom({ rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn { const editor = useEditor(); - const hasInitializedRef = useRef(false); - const hasRestoredPlayheadRef = useRef(false); - const scrollSaveTimeoutRef = useRef | null>( - null, - ); + const config: ZoomConfig = { + minZoom, + getContainerEl: () => containerRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + seek: (time) => editor.playback.seek({ time }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), + }; + const configRef = useRef(config); + configRef.current = config; - const [zoomLevel, setZoomLevelRaw] = useState(() => { - if (initialZoom !== undefined) { - hasInitializedRef.current = true; - return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)); - } - return minZoom; - }); - const previousZoomRef = useRef(zoomLevel); - const hasRestoredScrollRef = useRef(false); - const preZoomScrollLeftRef = useRef(0); - const prePlayheadAnchorScrollLeftRef = useRef(0); - const isInPlayheadAnchorModeRef = useRef(false); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ZoomController({ configRef, initialZoom }); + } + const controller = controllerRef.current; + const zoomLevel = controller.zoomLevel; - const setZoomLevel = useCallback( - (updater: number | ((prev: number) => number)) => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - preZoomScrollLeftRef.current = scrollElement.scrollLeft; - } - setZoomLevelRaw(updater); - }, - [tracksScrollRef], - ); - - const handleWheel = useCallback( - (event: ReactWheelEvent) => { - const isZoomGesture = event.ctrlKey || event.metaKey; - const isHorizontalScrollGesture = - event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); - - if (isHorizontalScrollGesture) { - return; - } - - // pinch-zoom (ctrl/meta + wheel) - if (isZoomGesture) { - const normalizedDelta = - event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; - const cappedDelta = - Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - setZoomLevel((prev) => { - const nextZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor), - ); - return nextZoom; - }); - return; - } - }, - [minZoom, setZoomLevel], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (initialZoom !== undefined && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); - return; - } - setZoomLevel((prev: number) => { - if (prev < minZoom) { - return minZoom; - } - return prev; - }); - }, [minZoom, initialZoom, setZoomLevel]); - - const wrappedSetZoomLevel = useCallback( - (zoomLevelOrUpdater: number | ((prev: number) => number)) => { - setZoomLevel((prev: number) => { - const nextZoom = - typeof zoomLevelOrUpdater === "function" - ? zoomLevelOrUpdater(prev) - : zoomLevelOrUpdater; - const clampedZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, nextZoom), - ); - return clampedZoom; - }); - }, - [minZoom, setZoomLevel], - ); + controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { - const previousZoom = previousZoomRef.current; - if (previousZoom === zoomLevel) return; - - const scrollElement = tracksScrollRef.current; - if (!scrollElement) { - previousZoomRef.current = zoomLevel; - return; - } - - const currentScrollLeft = preZoomScrollLeftRef.current; - const playheadTime = editor.playback.getCurrentTime(); - const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); - const previousSliderPercent = zoomToSlider({ - zoomLevel: previousZoom, - minZoom, - }); - const isCrossingThresholdUp = - previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - const isCrossingThresholdDown = - previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - - const syncScroll = (scrollLeft: number) => { - scrollElement.scrollLeft = scrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = scrollLeft; - } - }; - - const clampScrollLeft = (scrollLeft: number) => { - const maxScrollLeft = - scrollElement.scrollWidth - scrollElement.clientWidth; - return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); - }; - - if (isCrossingThresholdUp) { - prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; - isInPlayheadAnchorModeRef.current = true; - } - - if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { - const playheadPixelsBefore = timelineTimeToPixels({ - time: playheadTime, - zoomLevel: previousZoom, - }); - const playheadPixelsAfter = timelineTimeToPixels({ - time: playheadTime, - zoomLevel, - }); - - const viewportOffset = playheadPixelsBefore - currentScrollLeft; - const newScrollLeft = playheadPixelsAfter - viewportOffset; - - syncScroll(clampScrollLeft(newScrollLeft)); - } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { - syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); - isInPlayheadAnchorModeRef.current = false; - } - - previousZoomRef.current = zoomLevel; - - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime, - }, - }); - }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref - const saveScrollPosition = useCallback(() => { - if (scrollSaveTimeoutRef.current) { - clearTimeout(scrollSaveTimeoutRef.current); - } - scrollSaveTimeoutRef.current = setTimeout(() => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime: editor.playback.getCurrentTime(), - }, - }); - } - }, 300); - }, [zoomLevel, editor]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable - useEffect(() => { - if (initialScrollLeft === undefined) return; - if (hasRestoredScrollRef.current) return; - const scrollElement = tracksScrollRef.current; - if (!scrollElement) return; - - const restoreScroll = () => { - scrollElement.scrollLeft = initialScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = initialScrollLeft; - } - hasRestoredScrollRef.current = true; - }; - - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - } else { - const observer = new ResizeObserver(() => { - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - observer.disconnect(); - } - }); - observer.observe(scrollElement); - return () => observer.disconnect(); - } - }, [initialScrollLeft]); + controller.applyZoomLayout(zoomLevel); + }, [controller, zoomLevel]); useEffect(() => { - if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { - hasRestoredPlayheadRef.current = true; - editor.playback.seek({ time: initialPlayheadTime }); - } - }, [initialPlayheadTime, editor]); + return controller.restoreInitialScrollIfNeeded(initialScrollLeft); + }, [controller, initialScrollLeft]); - // prevent browser zoom in the timeline useEffect(() => { - const preventZoom = (event: WheelEvent) => { - const isZoomKeyPressed = event.ctrlKey || event.metaKey; - const isInContainer = containerRef.current?.contains( - event.target as Node, - ); - // only check isInContainer, not isInTimeline state - the state check - // causes race conditions where the closure captures stale state - if (isZoomKeyPressed && isInContainer) { - event.preventDefault(); - } - }; + controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime); + }, [controller, initialPlayheadTime]); - document.addEventListener("wheel", preventZoom, { - passive: false, - capture: true, - }); + useEffect(() => controller.bindPreventBrowserZoom(), [controller]); - return () => { - document.removeEventListener("wheel", preventZoom, { capture: true }); - }; - }, [containerRef]); + useEffect(() => () => controller.destroy(), [controller]); return { zoomLevel, - setZoomLevel: wrappedSetZoomLevel, - handleWheel, - saveScrollPosition, + setZoomLevel: controller.setZoomLevel, + handleWheel: controller.handleWheel, + saveScrollPosition: controller.saveScrollPosition, }; } diff --git a/apps/web/src/timeline/playhead-snap-source.ts b/apps/web/src/timeline/playhead-snap-source.ts index 88e21eba..0b41e17b 100644 --- a/apps/web/src/timeline/playhead-snap-source.ts +++ b/apps/web/src/timeline/playhead-snap-source.ts @@ -1,9 +1,10 @@ import type { SnapPoint } from "@/timeline/snapping"; +import type { MediaTime } from "@/wasm"; export function getPlayheadSnapPoints({ playheadTime, }: { - playheadTime: number; + playheadTime: MediaTime; }): SnapPoint[] { return [{ time: playheadTime, type: "playhead" }]; } diff --git a/apps/web/src/timeline/snapping/resolve.ts b/apps/web/src/timeline/snapping/resolve.ts index 2f00d081..dcbc670d 100644 --- a/apps/web/src/timeline/snapping/resolve.ts +++ b/apps/web/src/timeline/snapping/resolve.ts @@ -1,11 +1,12 @@ import type { SnapPoint, SnapResult } from "./types"; +import type { MediaTime } from "@/wasm"; export function resolveTimelineSnap({ targetTime, snapPoints, maxSnapDistance, }: { - targetTime: number; + targetTime: MediaTime; snapPoints: SnapPoint[]; maxSnapDistance: number; }): SnapResult { diff --git a/apps/web/src/timeline/snapping/types.ts b/apps/web/src/timeline/snapping/types.ts index 2a6d0903..7bffd84f 100644 --- a/apps/web/src/timeline/snapping/types.ts +++ b/apps/web/src/timeline/snapping/types.ts @@ -1,3 +1,5 @@ +import type { MediaTime } from "@/wasm"; + export type SnapPointType = | "element-start" | "element-end" @@ -6,14 +8,14 @@ export type SnapPointType = | "keyframe"; export interface SnapPoint { - time: number; + time: MediaTime; type: SnapPointType; elementId?: string; trackId?: string; } export interface SnapResult { - snappedTime: number; + snappedTime: MediaTime; snapPoint: SnapPoint | null; snapDistance: number; } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index 2a30a065..99df3128 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -284,6 +284,23 @@ export interface ElementDragState { currentMouseY: number; } +export type ElementDragView = + | { readonly kind: "idle" } + | { + readonly kind: "dragging"; + readonly anchorElementId: string; + readonly trackId: string; + readonly memberTimeOffsets: ReadonlyMap; + readonly startMouseX: number; + readonly startMouseY: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; + readonly currentTime: MediaTime; + readonly currentMouseX: number; + readonly currentMouseY: number; + readonly dropTarget: DropTarget | null; + }; + export interface DropTarget { trackIndex: number; isNewTrack: boolean; diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts index 7209e81c..82f5378d 100644 --- a/apps/web/src/wasm/media-time.ts +++ b/apps/web/src/wasm/media-time.ts @@ -1,7 +1,13 @@ import { + lastFrameTime as _lastFrameTime, + parseTimecode as _parseTimecode, + roundToFrame as _roundToFrame, + snappedSeekTime as _snappedSeekTime, TICKS_PER_SECOND as _TICKS_PER_SECOND, mediaTimeFromSeconds as _mediaTimeFromSeconds, mediaTimeToSeconds as _mediaTimeToSeconds, + type FrameRate, + type TimeCodeFormat, } from "opencut-wasm"; /** @@ -127,3 +133,58 @@ export function clampMediaTime({ if (time > max) return max; return time; } + +export function roundFrameTime({ + time, + fps, +}: { + time: MediaTime; + fps: FrameRate; +}): MediaTime { + return (_roundToFrame({ time, rate: fps }) ?? time) as MediaTime; +} + +export function roundFrameTicks({ + ticks, + fps, +}: { + ticks: number; + fps: FrameRate; +}): number { + return _roundToFrame({ time: ticks, rate: fps }) ?? ticks; +} + +export function snapSeekMediaTime({ + time, + duration, + fps, +}: { + time: MediaTime; + duration: MediaTime; + fps: FrameRate; +}): MediaTime { + return (_snappedSeekTime({ time, duration, rate: fps }) ?? time) as MediaTime; +} + +export function lastFrameMediaTime({ + duration, + fps, +}: { + duration: MediaTime; + fps: FrameRate; +}): MediaTime { + return (_lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime; +} + +export function parseMediaTimecode({ + timeCode, + format, + fps, +}: { + timeCode: string; + format: TimeCodeFormat; + fps: FrameRate; +}): MediaTime | null { + const parsedTime = _parseTimecode({ timeCode, format, rate: fps }); + return parsedTime == null ? null : (parsedTime as MediaTime); +} diff --git a/bun.lock b/bun.lock index cb36d56e..e342cd5f 100644 --- a/bun.lock +++ b/bun.lock @@ -54,7 +54,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.2.8", + "opencut-wasm": "^0.2.9", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", @@ -785,7 +785,7 @@ "@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="], - "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="], @@ -875,7 +875,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1359,7 +1359,7 @@ "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], - "opencut-wasm": ["opencut-wasm@0.2.8", "", {}, "sha512-R1cXB4HuTC5RdbGRYD0Q2n9rWQWDRB7KMwGJnbjrhnnwxBbR5XDu/3tL7hlzb/RZ7VZcaX5OGHZ1QgL6ex/2cQ=="], + "opencut-wasm": ["opencut-wasm@0.2.9", "", {}, "sha512-yZilhRRgNA02XY9Bq2yt6FidbnDQS1OYsvtNczxF6jL+nvl3vRboG0owwpQY0SPIbeJoJjJBuOVy0i1Pp6JS/w=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], From 6daa69635be77eefff145e32cb9cf3434390fc8d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 20:51:32 +0200 Subject: [PATCH 09/29] fix: restore README header, badges, links, and star chart Made-with: Cursor --- README.md | 71 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 5fe7c25f..af46df56 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,26 @@ - -| | | -| --- | ---------------------------------------------------------------------- | -| | OpenCutA free, open-source video editor for web, desktop, and mobile. | - + + + + + +
+ OpenCut Logo + +

OpenCut

+

A free, open-source video editor for web, desktop, and mobile.

+
## Sponsors Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software. + + Vercel OSS Program + - - + + Powered by fal.ai + ## Why? @@ -37,23 +47,29 @@ Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss ### Setup 1. Fork and clone the repository + 2. Copy the environment file: - ```bash + + ```bash # Unix/Linux/Mac cp apps/web/.env.example apps/web/.env.local # Windows PowerShell Copy-Item apps/web/.env.example apps/web/.env.local - ``` + ``` + 3. Start the database and Redis: - ```bash + + ```bash docker compose up -d db redis serverless-redis-http - ``` + ``` + 4. Install dependencies and start the dev server: - ```bash + + ```bash bun install bun dev:web - ``` + ``` The application will be available at [http://localhost:3000](http://localhost:3000). @@ -63,7 +79,7 @@ The `.env.example` has sensible defaults that match the Docker Compose config Desktop is opt-in. If you're only working on the web app, skip this entirely. -If you want to get ready for `apps/desktop`, see `[apps/desktop/README.md](apps/desktop/README.md)`. It's a two-step setup: Rust toolchain first, then desktop native dependencies. +If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies. ### Local WASM development @@ -83,23 +99,30 @@ cargo install cargo-watch ``` 1. Build the package once from the repo root: - ```bash + + ```bash bun run build:wasm - ``` + ``` + 2. Register the generated package for linking: - ```bash + + ```bash cd rust/wasm/pkg bun link - ``` + ``` + 3. Link `apps/web` to the local package: - ```bash + + ```bash cd apps/web bun link opencut-wasm - ``` + ``` + 4. Rebuild on changes while you work: - ```bash + + ```bash bun dev:wasm - ``` + ``` To switch `apps/web` back to the published package, run: @@ -132,7 +155,7 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc - Fork the repo and clone locally - Follow the setup instructions in CONTRIBUTING.md -- Working on `apps/desktop`? See `[apps/desktop/README.md](apps/desktop/README.md)` for setup +- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup - Create a feature branch and submit a PR ## License @@ -141,4 +164,4 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc --- -Star History Chart \ No newline at end of file +![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date) From 6ec818fc11334092dd2d068b2735d04aa0ff04e4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 21:31:12 +0200 Subject: [PATCH 10/29] chore: remove accidental file Made-with: Cursor --- .gitignore | 2 +- random_gibberish.txt | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 random_gibberish.txt diff --git a/.gitignore b/.gitignore index 2909d741..fa2b02bf 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ bun.lockb target/ -.cargo-tools/ \ No newline at end of file +.cargo-tools/random_gibberish.txt diff --git a/random_gibberish.txt b/random_gibberish.txt deleted file mode 100644 index 6a8c5579..00000000 --- a/random_gibberish.txt +++ /dev/null @@ -1,9 +0,0 @@ -bananas are not real -the moon was invented in 1987 by a consortium of cheese manufacturers -every time you blink, a squirrel forgets how to count -this file contains exactly 0 useful information -potatoes dream in hexadecimal -the ocean is just a very large puddle that got too confident -chairs were banned in 1423 but nobody noticed -purple smells like thursday -EOF From d6622dc6b3523a5658cd512b44f15f7a66fd540c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 2 May 2026 03:10:08 +0200 Subject: [PATCH 11/29] chore: untrack cargo-tools files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fa2b02bf..2909d741 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ bun.lockb target/ -.cargo-tools/random_gibberish.txt +.cargo-tools/ \ No newline at end of file From a9b9cf6bf50f08723896659634f2e6afc17bcd42 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 00:37:56 +0200 Subject: [PATCH 12/29] chore: switch from biome to eslint + prettier; fix ton of lint issues --- .github/CONTRIBUTING.md | 6 +- .github/copilot-instructions.md | 686 +++++----- .prettierignore | 6 + .prettierrc.json | 3 + .vscode/settings.json | 11 +- AGENTS.md | 14 - apps/web/package.json | 6 +- .../actions/components/shortcuts-dialog.tsx | 13 +- apps/web/src/actions/definitions.ts | 75 +- apps/web/src/actions/keybinding.ts | 122 +- apps/web/src/actions/keybindings-store.ts | 195 +-- .../keybindings/__tests__/persistence.test.ts | 144 ++ .../keybindings/migrations/v2-to-v3.ts | 16 +- .../keybindings/migrations/v3-to-v4.ts | 17 +- .../keybindings/migrations/v4-to-v5.ts | 10 +- .../keybindings/migrations/v5-to-v6.ts | 10 +- .../keybindings/migrations/v6-to-v7.ts | 14 +- .../actions/keybindings/persisted-state.ts | 37 + .../src/actions/keybindings/persistence.ts | 119 ++ apps/web/src/actions/registry.ts | 3 + apps/web/src/actions/types.ts | 88 +- apps/web/src/actions/use-action-handler.ts | 1 + apps/web/src/actions/use-editor-actions.ts | 46 +- apps/web/src/actions/use-keybindings.ts | 2 +- .../actions/use-keyboard-shortcuts-help.ts | 26 +- apps/web/src/animation/property-registry.ts | 119 +- apps/web/src/app/brand/page.tsx | 2 +- apps/web/src/app/editor/[project_id]/page.tsx | 19 +- apps/web/src/app/privacy/page.tsx | 11 +- apps/web/src/app/terms/page.tsx | 28 +- apps/web/src/clipboard/handlers/elements.ts | 2 +- apps/web/src/clipboard/handlers/index.ts | 2 +- apps/web/src/clipboard/handlers/keyframes.ts | 5 +- apps/web/src/clipboard/types.ts | 8 +- .../web/src/commands/media/add-media-asset.ts | 25 +- .../src/commands/media/remove-media-asset.ts | 16 +- apps/web/src/commands/scene/create-scene.ts | 16 +- apps/web/src/commands/scene/move-bookmark.ts | 16 +- .../web/src/commands/scene/remove-bookmark.ts | 4 +- apps/web/src/commands/scene/rename-scene.ts | 16 +- .../web/src/commands/scene/toggle-bookmark.ts | 4 +- .../web/src/commands/scene/update-bookmark.ts | 16 +- .../src/commands/timeline/track/add-track.ts | 16 +- .../src/commands/timeline/tracks-snapshot.ts | 16 +- .../panels/assets/assets-panel-store.tsx | 4 +- .../editor/panels/assets/views/assets.tsx | 7 +- .../panels/assets/views/settings/index.tsx | 32 +- .../hooks/use-keyframed-color-property.ts | 192 +-- .../hooks/use-keyframed-number-property.ts | 350 ++--- .../properties/hooks/use-property-draft.ts | 43 +- .../editor/panels/properties/index.tsx | 7 +- .../properties/stores/properties-store.ts | 9 +- apps/web/src/core/index.ts | 2 +- apps/web/src/core/managers/media-manager.ts | 12 +- apps/web/src/core/managers/save-manager.ts | 13 +- apps/web/src/core/managers/scenes-manager.ts | 11 +- .../web/src/core/managers/timeline-manager.ts | 7 +- apps/web/src/editor/panel-store.ts | 4 +- apps/web/src/editor/use-editor.ts | 36 +- apps/web/src/effects/definitions/index.ts | 5 +- apps/web/src/fps/utils.ts | 15 +- .../src/graphics/components/graphic-tab.tsx | 478 +++---- apps/web/src/graphics/definitions/index.ts | 5 +- apps/web/src/graphics/index.ts | 304 +++-- apps/web/src/hooks/use-committed-ref.ts | 11 + apps/web/src/hooks/use-focus-lock.ts | 15 +- apps/web/src/masks/custom-path.ts | 42 +- apps/web/src/masks/definitions/split.ts | 26 +- apps/web/src/masks/registry.ts | 5 +- apps/web/src/media/use-paste-media.ts | 6 +- apps/web/src/params/index.ts | 100 +- apps/web/src/params/registry.ts | 86 +- .../src/preview/components/context-menu.tsx | 6 +- apps/web/src/preview/components/index.tsx | 26 +- .../preview/components/preview-viewport.tsx | 16 +- .../preview/components/text-edit-overlay.tsx | 1 - .../preview/hooks/use-preview-interaction.ts | 18 +- .../preview/hooks/use-transform-handles.ts | 16 +- .../src/rendering/components/blending-tab.tsx | 231 ---- .../rendering/components/transform-tab.tsx | 462 ------- .../web/src/selection/hooks/use-box-select.ts | 85 +- .../selection/hooks/use-selection-scope.ts | 19 +- apps/web/src/selection/selectable-item.tsx | 10 +- apps/web/src/selection/selectable-surface.tsx | 26 +- apps/web/src/selection/selection-box.tsx | 47 +- apps/web/src/selection/types.ts | 7 + .../src/services/renderer/canvas-renderer.ts | 41 +- .../web/src/services/renderer/canvas-utils.ts | 19 +- .../renderer/compositor/frame-descriptor.ts | 23 +- .../src/services/renderer/effect-preview.ts | 25 +- .../web/src/services/renderer/gpu-renderer.ts | 53 +- .../web/src/services/renderer/mask-feather.ts | 6 +- .../services/renderer/nodes/graphic-node.ts | 17 +- .../src/services/renderer/nodes/image-node.ts | 11 +- apps/web/src/services/renderer/resolve.ts | 10 +- .../src/services/storage/indexeddb-adapter.ts | 18 +- .../storage/migrations/__tests__/helpers.ts | 39 + .../migrations/__tests__/v0-to-v1.test.ts | 7 +- .../migrations/__tests__/v1-to-v2.test.ts | 41 +- .../migrations/__tests__/v15-to-v16.test.ts | 9 +- .../migrations/__tests__/v16-to-v17.test.ts | 13 +- .../migrations/__tests__/v18-to-v19.test.ts | 17 +- .../migrations/__tests__/v19-to-v20.test.ts | 21 +- .../migrations/__tests__/v2-to-v3.test.ts | 17 +- .../migrations/__tests__/v20-to-v21.test.ts | 11 +- .../migrations/__tests__/v21-to-v22.test.ts | 13 +- .../migrations/__tests__/v22-to-v23.test.ts | 20 +- .../migrations/__tests__/v26-to-v27.test.ts | 15 +- .../migrations/__tests__/v27-to-v28.test.ts | 37 +- .../migrations/__tests__/v3-to-v4.test.ts | 25 +- .../migrations/__tests__/v4-to-v5.test.ts | 25 +- .../migrations/__tests__/v5-to-v6.test.ts | 17 +- .../migrations/__tests__/v8-to-v9.test.ts | 26 +- .../migrations/transformers/v1-to-v2.ts | 104 +- .../migrations/transformers/v13-to-v14.ts | 5 +- .../migrations/transformers/v26-to-v27.ts | 17 +- .../migrations/transformers/v3-to-v4.ts | 15 +- apps/web/src/services/storage/opfs-adapter.ts | 8 +- apps/web/src/services/storage/service.ts | 55 +- apps/web/src/services/storage/types.ts | 2 +- apps/web/src/stickers/providers/index.ts | 48 +- .../src/subtitles/components/assets-view.tsx | 2 + apps/web/src/subtitles/insert.ts | 2 +- apps/web/src/text/components/assets-view.tsx | 94 +- apps/web/src/text/components/text-tab.tsx | 767 ----------- .../__tests__/update-pipeline.test.ts | 132 +- apps/web/src/timeline/animation-properties.ts | 426 ------ apps/web/src/timeline/animation-targets.ts | 2 +- .../timeline/components/audio-volume-line.tsx | 3 +- .../timeline/components/audio-waveform.tsx | 60 +- .../components/graph-editor/bezier-graph.tsx | 5 +- apps/web/src/timeline/components/index.tsx | 41 +- .../timeline/components/timeline-element.tsx | 59 +- .../timeline/components/timeline-playhead.tsx | 12 +- .../timeline/components/timeline-ruler.tsx | 19 +- .../timeline/components/timeline-track.tsx | 6 +- .../controllers/drag-drop-controller.ts | 5 +- .../element-interaction-controller.ts | 85 +- .../timeline/controllers/resize-controller.ts | 13 +- .../timeline/controllers/zoom-controller.ts | 25 +- .../hooks/element/use-element-interaction.ts | 18 +- .../hooks/element/use-keyframe-drag.ts | 16 +- .../hooks/use-snap-indicator-position.ts | 24 +- .../timeline/hooks/use-timeline-drag-drop.ts | 14 +- .../timeline/hooks/use-timeline-playhead.ts | 13 +- .../src/timeline/hooks/use-timeline-resize.ts | 14 +- .../src/timeline/hooks/use-timeline-seek.ts | 14 +- .../src/timeline/hooks/use-timeline-zoom.ts | 17 +- apps/web/src/timeline/index.ts | 9 +- apps/web/src/timeline/types.ts | 664 ++++----- apps/web/src/utils/geometry.ts | 10 +- apps/web/src/wasm/media-time.ts | 90 +- apps/web/tsconfig.json | 78 +- bun.lock | 1196 ++++++++++++----- eslint.config.mjs | 96 ++ .../__tests__/prefer-object-params.test.mjs | 112 ++ eslint/rules/prefer-object-params.mjs | 107 ++ package.json | 20 +- text | 977 ++++++++++++++ 159 files changed, 5630 insertions(+), 5255 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 apps/web/src/actions/keybindings/__tests__/persistence.test.ts create mode 100644 apps/web/src/actions/keybindings/persisted-state.ts create mode 100644 apps/web/src/actions/keybindings/persistence.ts create mode 100644 apps/web/src/hooks/use-committed-ref.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/helpers.ts delete mode 100644 apps/web/src/timeline/animation-properties.ts create mode 100644 eslint.config.mjs create mode 100644 eslint/rules/__tests__/prefer-object-params.test.mjs create mode 100644 eslint/rules/prefer-object-params.mjs create mode 100644 text diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f251603f..5edc56fb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README 2. Make your changes 3. Run the relevant checks for the area you touched: - - Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .` + - Web changes: from `apps/web`, run `bun run lint` and `bun run format` - Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet 4. Commit your changes with a descriptive message @@ -176,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README ## Code Style -- We use Biome for code formatting and linting -- Run `bunx biome format --write .` from the `apps/web` directory to format code +- We use ESLint for linting and Prettier for formatting +- Run `bun run format` from the `apps/web` directory to format code - Run `bun run lint` from the `apps/web` directory to check for linting issues - Follow the existing code patterns diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c30aecc2..77d2c4f0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,344 +1,342 @@ ---- -applyTo: "**/*.{ts,tsx,js,jsx}" ---- - -# Project Context - -Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. - -## Key Principles - -- Zero configuration required -- Subsecond performance -- Maximum type safety -- AI-friendly code generation - -## Before Writing Code - -1. Analyze existing patterns in the codebase -2. Consider edge cases and error scenarios -3. Follow the rules below strictly -4. Validate accessibility requirements - -## Rules - -### Accessibility (a11y) - -- Don't use `accessKey` attribute on any HTML element. -- Don't set `aria-hidden="true"` on focusable elements. -- Don't add ARIA roles, states, and properties to elements that don't support them. -- Don't use distracting elements like `` or ``. -- Only use the `scope` prop on `` elements. -- Don't assign non-interactive ARIA roles to interactive HTML elements. -- Make sure label elements have text content and are associated with an input. -- Don't assign interactive ARIA roles to non-interactive HTML elements. -- Don't assign `tabIndex` to non-interactive HTML elements. -- Don't use positive integers for `tabIndex` property. -- Don't include "image", "picture", or "photo" in img alt prop. -- Don't use explicit role property that's the same as the implicit/default role. -- Make static elements with click handlers use a valid role attribute. -- Always include a `title` element for SVG elements. -- Give all elements requiring alt text meaningful information for screen readers. -- Make sure anchors have content that's accessible to screen readers. -- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. -- Include all required ARIA attributes for elements with ARIA roles. -- Make sure ARIA properties are valid for the element's supported roles. -- Always include a `type` attribute for button elements. -- Make elements with interactive roles and handlers focusable. -- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). -- Always include a `lang` attribute on the html element. -- Always include a `title` attribute for iframe elements. -- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. -- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. -- Include caption tracks for audio and video elements. -- Use semantic elements instead of role attributes in JSX. -- Make sure all anchors are valid and navigable. -- Ensure all ARIA properties (`aria-*`) are valid. -- Use valid, non-abstract ARIA roles for elements with ARIA roles. -- Use valid ARIA state and property values. -- Use valid values for the `autocomplete` attribute on input elements. -- Use correct ISO language/country codes for the `lang` attribute. - -### Code Complexity and Quality - -- Don't use consecutive spaces in regular expression literals. -- Don't use the `arguments` object. -- Don't use primitive type aliases or misleading types. -- Don't use the comma operator. -- Don't use empty type parameters in type aliases and interfaces. -- Don't write functions that exceed a given Cognitive Complexity score. -- Don't nest describe() blocks too deeply in test files. -- Don't use unnecessary boolean casts. -- Don't use unnecessary callbacks with flatMap. -- Use for...of statements instead of Array.forEach. -- Don't create classes that only have static members (like a static namespace). -- Don't use this and super in static contexts. -- Don't use unnecessary catch clauses. -- Don't use unnecessary constructors. -- Don't use unnecessary continue statements. -- Don't export empty modules that don't change anything. -- Don't use unnecessary escape sequences in regular expression literals. -- Don't use unnecessary fragments. -- Don't use unnecessary labels. -- Don't use unnecessary nested block statements. -- Don't rename imports, exports, and destructured assignments to the same name. -- Don't use unnecessary string or template literal concatenation. -- Don't use String.raw in template literals when there are no escape sequences. -- Don't use useless case statements in switch statements. -- Don't use ternary operators when simpler alternatives exist. -- Don't use useless `this` aliasing. -- Don't use any or unknown as type constraints. -- Don't initialize variables to undefined. -- Don't use the void operators (they're not familiar). -- Use arrow functions instead of function expressions. -- Use Date.now() to get milliseconds since the Unix Epoch. -- Use .flatMap() instead of map().flat() when possible. -- Use literal property access instead of computed property access. -- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. -- Use concise optional chaining instead of chained logical expressions. -- Use regular expression literals instead of the RegExp constructor when possible. -- Don't use number literal object member names that aren't base 10 or use underscore separators. -- Remove redundant terms from logical expressions. -- Use while loops instead of for loops when you don't need initializer and update expressions. -- Don't pass children as props. -- Don't reassign const variables. -- Don't use constant expressions in conditions. -- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. -- Don't return a value from a constructor. -- Don't use empty character classes in regular expression literals. -- Don't use empty destructuring patterns. -- Don't call global object properties as functions. -- Don't declare functions and vars that are accessible outside their block. -- Make sure builtins are correctly instantiated. -- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. -- Don't use variables and function parameters before they're declared. -- Don't use 8 and 9 escape sequences in string literals. -- Don't use literal numbers that lose precision. - -### React and JSX Best Practices - -- Don't use the return value of React.render. -- Make sure all dependencies are correctly specified in React hooks. -- Make sure all React hooks are called from the top level of component functions. -- Don't forget key props in iterators and collection literals. -- Don't destructure props inside JSX components in Solid projects. -- Don't define React components inside other components. -- Don't use event handlers on non-interactive elements. -- Don't assign to React component props. -- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. -- Don't use dangerous JSX props. -- Don't use Array index in keys. -- Don't insert comments as text nodes. -- Don't assign JSX properties multiple times. -- Don't add extra closing tags for components without children. -- Use `<>...` instead of `...`. -- Watch out for possible "wrong" semicolons inside JSX elements. - -### Correctness and Safety - -- Don't assign a value to itself. -- Don't return a value from a setter. -- Don't compare expressions that modify string case with non-compliant values. -- Don't use lexical declarations in switch clauses. -- Don't use variables that haven't been declared in the document. -- Don't write unreachable code. -- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. -- Don't use control flow statements in finally blocks. -- Don't use optional chaining where undefined values aren't allowed. -- Don't have unused function parameters. -- Don't have unused imports. -- Don't have unused labels. -- Don't have unused private class members. -- Don't have unused variables. -- Make sure void (self-closing) elements don't have children. -- Don't return a value from a function with the return type 'void' -- Use isNaN() when checking for NaN. -- Make sure "for" loop update clauses move the counter in the right direction. -- Make sure typeof expressions are compared to valid values. -- Make sure generator functions contain yield. -- Don't use await inside loops. -- Don't use bitwise operators. -- Don't use expressions where the operation doesn't change the value. -- Make sure Promise-like statements are handled appropriately. -- Don't use **dirname and **filename in the global scope. -- Prevent import cycles. -- Don't use configured elements. -- Don't hardcode sensitive data like API keys and tokens. -- Don't let variable declarations shadow variables from outer scopes. -- Don't use the TypeScript directive @ts-ignore. -- Prevent duplicate polyfills from Polyfill.io. -- Don't use useless backreferences in regular expressions that always match empty strings. -- Don't use unnecessary escapes in string literals. -- Don't use useless undefined. -- Make sure getters and setters for the same property are next to each other in class and object definitions. -- Make sure object literals are declared consistently (defaults to explicit definitions). -- Use static Response methods instead of new Response() constructor when possible. -- Make sure switch-case statements are exhaustive. -- Make sure the `preconnect` attribute is used when using Google Fonts. -- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. -- Make sure iterable callbacks return consistent values. -- Use `with { type: "json" }` for JSON module imports. -- Use numeric separators in numeric literals. -- Use object spread instead of `Object.assign()` when constructing new objects. -- Always use the radix argument when using `parseInt()`. -- Make sure JSDoc comment lines start with a single asterisk, except for the first one. -- Include a description parameter for `Symbol()`. -- Don't use spread (`...`) syntax on accumulators. -- Don't use the `delete` operator. -- Don't access namespace imports dynamically. -- Don't use namespace imports. -- Declare regex literals at the top level. -- Don't use `target="_blank"` without `rel="noopener"`. - -### TypeScript Best Practices - -- Don't use TypeScript enums. -- Don't export imported variables. -- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. -- Don't use TypeScript namespaces. -- Don't use non-null assertions with the `!` postfix operator. -- Don't use parameter properties in class constructors. -- Don't use user-defined types. -- Use `as const` instead of literal types and type annotations. -- Use either `T[]` or `Array` consistently. -- Initialize each enum member value explicitly. -- Use `export type` for types. -- Use `import type` for types. -- Make sure all enum members are literal values. -- Don't use TypeScript const enum. -- Don't declare empty interfaces. -- Don't let variables evolve into any type through reassignments. -- Don't use the any type. -- Don't misuse the non-null assertion operator (!) in TypeScript files. -- Don't use implicit any type on variable declarations. -- Don't merge interfaces and classes unsafely. -- Don't use overload signatures that aren't next to each other. -- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. - -### Style and Consistency - -- Don't use global `eval()`. -- Don't use callbacks in asynchronous tests and hooks. -- Don't use negation in `if` statements that have `else` clauses. -- Don't use nested ternary expressions. -- Don't reassign function parameters. -- This rule lets you specify global variable names you don't want to use in your application. -- Don't use specified modules when loaded by import or require. -- Don't use constants whose value is the upper-case version of their name. -- Use `String.slice()` instead of `String.substr()` and `String.substring()`. -- Don't use template literals if you don't need interpolation or special-character handling. -- Don't use `else` blocks when the `if` block breaks early. -- Don't use yoda expressions. -- Don't use Array constructors. -- Use `at()` instead of integer index access. -- Follow curly brace conventions. -- Use `else if` instead of nested `if` statements in `else` clauses. -- Use single `if` statements instead of nested `if` clauses. -- Use `new` for all builtins except `String`, `Number`, and `Boolean`. -- Use consistent accessibility modifiers on class properties and methods. -- Use `const` declarations for variables that are only assigned once. -- Put default function parameters and optional function parameters last. -- Include a `default` clause in switch statements. -- Use the `**` operator instead of `Math.pow`. -- Use `for-of` loops when you need the index to extract an item from the iterated array. -- Use `node:assert/strict` over `node:assert`. -- Use the `node:` protocol for Node.js builtin modules. -- Use Number properties instead of global ones. -- Use assignment operator shorthand where possible. -- Use function types instead of object types with call signatures. -- Use template literals over string concatenation. -- Use `new` when throwing an error. -- Don't throw non-Error values. -- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. -- Use standard constants instead of approximated literals. -- Don't assign values in expressions. -- Don't use async functions as Promise executors. -- Don't reassign exceptions in catch clauses. -- Don't reassign class members. -- Don't compare against -0. -- Don't use labeled statements that aren't loops. -- Don't use void type outside of generic or return types. -- Don't use console. -- Don't use control characters and escape sequences that match control characters in regular expression literals. -- Don't use debugger. -- Don't assign directly to document.cookie. -- Use `===` and `!==`. -- Don't use duplicate case labels. -- Don't use duplicate class members. -- Don't use duplicate conditions in if-else-if chains. -- Don't use two keys with the same name inside objects. -- Don't use duplicate function parameter names. -- Don't have duplicate hooks in describe blocks. -- Don't use empty block statements and static blocks. -- Don't let switch clauses fall through. -- Don't reassign function declarations. -- Don't allow assignments to native objects and read-only global variables. -- Use Number.isFinite instead of global isFinite. -- Use Number.isNaN instead of global isNaN. -- Don't assign to imported bindings. -- Don't use irregular whitespace characters. -- Don't use labels that share a name with a variable. -- Don't use characters made with multiple code points in character class syntax. -- Make sure to use new and constructor properly. -- Don't use shorthand assign when the variable appears on both sides. -- Don't use octal escape sequences in string literals. -- Don't use Object.prototype builtins directly. -- Don't redeclare variables, functions, classes, and types in the same scope. -- Don't have redundant "use strict". -- Don't compare things where both sides are exactly the same. -- Don't let identifiers shadow restricted names. -- Don't use sparse arrays (arrays with holes). -- Don't use template literal placeholder syntax in regular strings. -- Don't use the then property. -- Don't use unsafe negation. -- Don't use var. -- Don't use with statements in non-strict contexts. -- Make sure async functions actually use await. -- Make sure default clauses in switch statements come last. -- Make sure to pass a message value when creating a built-in error. -- Make sure get methods always return a value. -- Use a recommended display strategy with Google Fonts. -- Make sure for-in loops include an if statement. -- Use Array.isArray() instead of instanceof Array. -- Make sure to use the digits argument with Number#toFixed(). -- Make sure to use the "use strict" directive in script files. - -### Next.js Specific Rules - -- Don't use `` elements in Next.js projects. -- Don't use `` elements in Next.js projects. -- Don't import next/document outside of pages/\_document.jsx in Next.js projects. -- Don't use the next/head module in pages/\_document.js on Next.js projects. - -### Testing Best Practices - -- Don't use export or module.exports in test files. -- Don't use focused tests. -- Make sure the assertion function, like expect, is placed inside an it() function call. -- Don't use disabled tests. - -## Common Tasks - -- `npx ultracite init` - Initialize Ultracite in your project -- `npx ultracite format` - Format and fix code automatically -- `npx ultracite lint` - Check for issues without fixing - -## Example: Error Handling - -```typescript -// ✅ Good: Comprehensive error handling -try { - const result = await fetchData(); - return { success: true, data: result }; -} catch (error) { - console.error("API call failed:", error); - return { success: false, error: error.message }; -} - -// ❌ Bad: Swallowing errors -try { - return await fetchData(); -} catch (e) { - console.log(e); -} -``` +--- +applyTo: "**/*.{ts,tsx,js,jsx}" +--- + +# Project Context + +Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting). + +## Key Principles + +- Maximum type safety +- AI-friendly code generation + +## Before Writing Code + +1. Analyze existing patterns in the codebase +2. Consider edge cases and error scenarios +3. Follow the rules below strictly +4. Validate accessibility requirements + +## Rules + +### Accessibility (a11y) + +- Don't use `accessKey` attribute on any HTML element. +- Don't set `aria-hidden="true"` on focusable elements. +- Don't add ARIA roles, states, and properties to elements that don't support them. +- Don't use distracting elements like `` or ``. +- Only use the `scope` prop on `` elements. +- Don't assign non-interactive ARIA roles to interactive HTML elements. +- Make sure label elements have text content and are associated with an input. +- Don't assign interactive ARIA roles to non-interactive HTML elements. +- Don't assign `tabIndex` to non-interactive HTML elements. +- Don't use positive integers for `tabIndex` property. +- Don't include "image", "picture", or "photo" in img alt prop. +- Don't use explicit role property that's the same as the implicit/default role. +- Make static elements with click handlers use a valid role attribute. +- Always include a `title` element for SVG elements. +- Give all elements requiring alt text meaningful information for screen readers. +- Make sure anchors have content that's accessible to screen readers. +- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. +- Include all required ARIA attributes for elements with ARIA roles. +- Make sure ARIA properties are valid for the element's supported roles. +- Always include a `type` attribute for button elements. +- Make elements with interactive roles and handlers focusable. +- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). +- Always include a `lang` attribute on the html element. +- Always include a `title` attribute for iframe elements. +- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. +- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. +- Include caption tracks for audio and video elements. +- Use semantic elements instead of role attributes in JSX. +- Make sure all anchors are valid and navigable. +- Ensure all ARIA properties (`aria-*`) are valid. +- Use valid, non-abstract ARIA roles for elements with ARIA roles. +- Use valid ARIA state and property values. +- Use valid values for the `autocomplete` attribute on input elements. +- Use correct ISO language/country codes for the `lang` attribute. + +### Code Complexity and Quality + +- Don't use consecutive spaces in regular expression literals. +- Don't use the `arguments` object. +- Don't use primitive type aliases or misleading types. +- Don't use the comma operator. +- Don't use empty type parameters in type aliases and interfaces. +- Don't write functions that exceed a given Cognitive Complexity score. +- Don't nest describe() blocks too deeply in test files. +- Don't use unnecessary boolean casts. +- Don't use unnecessary callbacks with flatMap. +- Use for...of statements instead of Array.forEach. +- Don't create classes that only have static members (like a static namespace). +- Don't use this and super in static contexts. +- Don't use unnecessary catch clauses. +- Don't use unnecessary constructors. +- Don't use unnecessary continue statements. +- Don't export empty modules that don't change anything. +- Don't use unnecessary escape sequences in regular expression literals. +- Don't use unnecessary fragments. +- Don't use unnecessary labels. +- Don't use unnecessary nested block statements. +- Don't rename imports, exports, and destructured assignments to the same name. +- Don't use unnecessary string or template literal concatenation. +- Don't use String.raw in template literals when there are no escape sequences. +- Don't use useless case statements in switch statements. +- Don't use ternary operators when simpler alternatives exist. +- Don't use useless `this` aliasing. +- Don't use any or unknown as type constraints. +- Don't initialize variables to undefined. +- Don't use the void operators (they're not familiar). +- Use arrow functions instead of function expressions. +- Use Date.now() to get milliseconds since the Unix Epoch. +- Use .flatMap() instead of map().flat() when possible. +- Use literal property access instead of computed property access. +- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. +- Use concise optional chaining instead of chained logical expressions. +- Use regular expression literals instead of the RegExp constructor when possible. +- Don't use number literal object member names that aren't base 10 or use underscore separators. +- Remove redundant terms from logical expressions. +- Use while loops instead of for loops when you don't need initializer and update expressions. +- Don't pass children as props. +- Don't reassign const variables. +- Don't use constant expressions in conditions. +- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. +- Don't return a value from a constructor. +- Don't use empty character classes in regular expression literals. +- Don't use empty destructuring patterns. +- Don't call global object properties as functions. +- Don't declare functions and vars that are accessible outside their block. +- Make sure builtins are correctly instantiated. +- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. +- Don't use variables and function parameters before they're declared. +- Don't use 8 and 9 escape sequences in string literals. +- Don't use literal numbers that lose precision. + +### React and JSX Best Practices + +- Don't use the return value of React.render. +- Make sure all dependencies are correctly specified in React hooks. +- Make sure all React hooks are called from the top level of component functions. +- Don't forget key props in iterators and collection literals. +- Don't destructure props inside JSX components in Solid projects. +- Don't define React components inside other components. +- Don't use event handlers on non-interactive elements. +- Don't assign to React component props. +- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. +- Don't use dangerous JSX props. +- Don't use Array index in keys. +- Don't insert comments as text nodes. +- Don't assign JSX properties multiple times. +- Don't add extra closing tags for components without children. +- Use `<>...` instead of `...`. +- Watch out for possible "wrong" semicolons inside JSX elements. + +### Correctness and Safety + +- Don't assign a value to itself. +- Don't return a value from a setter. +- Don't compare expressions that modify string case with non-compliant values. +- Don't use lexical declarations in switch clauses. +- Don't use variables that haven't been declared in the document. +- Don't write unreachable code. +- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. +- Don't use control flow statements in finally blocks. +- Don't use optional chaining where undefined values aren't allowed. +- Don't have unused function parameters. +- Don't have unused imports. +- Don't have unused labels. +- Don't have unused private class members. +- Don't have unused variables. +- Make sure void (self-closing) elements don't have children. +- Don't return a value from a function with the return type 'void' +- Use isNaN() when checking for NaN. +- Make sure "for" loop update clauses move the counter in the right direction. +- Make sure typeof expressions are compared to valid values. +- Make sure generator functions contain yield. +- Don't use await inside loops. +- Don't use bitwise operators. +- Don't use expressions where the operation doesn't change the value. +- Make sure Promise-like statements are handled appropriately. +- Don't use **dirname and **filename in the global scope. +- Prevent import cycles. +- Don't use configured elements. +- Don't hardcode sensitive data like API keys and tokens. +- Don't let variable declarations shadow variables from outer scopes. +- Don't use the TypeScript directive @ts-ignore. +- Prevent duplicate polyfills from Polyfill.io. +- Don't use useless backreferences in regular expressions that always match empty strings. +- Don't use unnecessary escapes in string literals. +- Don't use useless undefined. +- Make sure getters and setters for the same property are next to each other in class and object definitions. +- Make sure object literals are declared consistently (defaults to explicit definitions). +- Use static Response methods instead of new Response() constructor when possible. +- Make sure switch-case statements are exhaustive. +- Make sure the `preconnect` attribute is used when using Google Fonts. +- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. +- Make sure iterable callbacks return consistent values. +- Use `with { type: "json" }` for JSON module imports. +- Use numeric separators in numeric literals. +- Use object spread instead of `Object.assign()` when constructing new objects. +- Always use the radix argument when using `parseInt()`. +- Make sure JSDoc comment lines start with a single asterisk, except for the first one. +- Include a description parameter for `Symbol()`. +- Don't use spread (`...`) syntax on accumulators. +- Don't use the `delete` operator. +- Don't access namespace imports dynamically. +- Don't use namespace imports. +- Declare regex literals at the top level. +- Don't use `target="_blank"` without `rel="noopener"`. + +### TypeScript Best Practices + +- Don't use TypeScript enums. +- Don't export imported variables. +- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. +- Don't use TypeScript namespaces. +- Don't use non-null assertions with the `!` postfix operator. +- Don't use parameter properties in class constructors. +- Don't use user-defined types. +- Use `as const` instead of literal types and type annotations. +- Use either `T[]` or `Array` consistently. +- Initialize each enum member value explicitly. +- Use `export type` for types. +- Use `import type` for types. +- Make sure all enum members are literal values. +- Don't use TypeScript const enum. +- Don't declare empty interfaces. +- Don't let variables evolve into any type through reassignments. +- Don't use the any type. +- Don't misuse the non-null assertion operator (!) in TypeScript files. +- Don't use implicit any type on variable declarations. +- Don't merge interfaces and classes unsafely. +- Don't use overload signatures that aren't next to each other. +- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. + +### Style and Consistency + +- Don't use global `eval()`. +- Don't use callbacks in asynchronous tests and hooks. +- Don't use negation in `if` statements that have `else` clauses. +- Don't use nested ternary expressions. +- Don't reassign function parameters. +- This rule lets you specify global variable names you don't want to use in your application. +- Don't use specified modules when loaded by import or require. +- Don't use constants whose value is the upper-case version of their name. +- Use `String.slice()` instead of `String.substr()` and `String.substring()`. +- Don't use template literals if you don't need interpolation or special-character handling. +- Don't use `else` blocks when the `if` block breaks early. +- Don't use yoda expressions. +- Don't use Array constructors. +- Use `at()` instead of integer index access. +- Follow curly brace conventions. +- Use `else if` instead of nested `if` statements in `else` clauses. +- Use single `if` statements instead of nested `if` clauses. +- Use `new` for all builtins except `String`, `Number`, and `Boolean`. +- Use consistent accessibility modifiers on class properties and methods. +- Use `const` declarations for variables that are only assigned once. +- Put default function parameters and optional function parameters last. +- Include a `default` clause in switch statements. +- Use the `**` operator instead of `Math.pow`. +- Use `for-of` loops when you need the index to extract an item from the iterated array. +- Use `node:assert/strict` over `node:assert`. +- Use the `node:` protocol for Node.js builtin modules. +- Use Number properties instead of global ones. +- Use assignment operator shorthand where possible. +- Use function types instead of object types with call signatures. +- Use template literals over string concatenation. +- Use `new` when throwing an error. +- Don't throw non-Error values. +- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. +- Use standard constants instead of approximated literals. +- Don't assign values in expressions. +- Don't use async functions as Promise executors. +- Don't reassign exceptions in catch clauses. +- Don't reassign class members. +- Don't compare against -0. +- Don't use labeled statements that aren't loops. +- Don't use void type outside of generic or return types. +- Don't use console. +- Don't use control characters and escape sequences that match control characters in regular expression literals. +- Don't use debugger. +- Don't assign directly to document.cookie. +- Use `===` and `!==`. +- Don't use duplicate case labels. +- Don't use duplicate class members. +- Don't use duplicate conditions in if-else-if chains. +- Don't use two keys with the same name inside objects. +- Don't use duplicate function parameter names. +- Don't have duplicate hooks in describe blocks. +- Don't use empty block statements and static blocks. +- Don't let switch clauses fall through. +- Don't reassign function declarations. +- Don't allow assignments to native objects and read-only global variables. +- Use Number.isFinite instead of global isFinite. +- Use Number.isNaN instead of global isNaN. +- Don't assign to imported bindings. +- Don't use irregular whitespace characters. +- Don't use labels that share a name with a variable. +- Don't use characters made with multiple code points in character class syntax. +- Make sure to use new and constructor properly. +- Don't use shorthand assign when the variable appears on both sides. +- Don't use octal escape sequences in string literals. +- Don't use Object.prototype builtins directly. +- Don't redeclare variables, functions, classes, and types in the same scope. +- Don't have redundant "use strict". +- Don't compare things where both sides are exactly the same. +- Don't let identifiers shadow restricted names. +- Don't use sparse arrays (arrays with holes). +- Don't use template literal placeholder syntax in regular strings. +- Don't use the then property. +- Don't use unsafe negation. +- Don't use var. +- Don't use with statements in non-strict contexts. +- Make sure async functions actually use await. +- Make sure default clauses in switch statements come last. +- Make sure to pass a message value when creating a built-in error. +- Make sure get methods always return a value. +- Use a recommended display strategy with Google Fonts. +- Make sure for-in loops include an if statement. +- Use Array.isArray() instead of instanceof Array. +- Make sure to use the digits argument with Number#toFixed(). +- Make sure to use the "use strict" directive in script files. + +### Next.js Specific Rules + +- Don't use `` elements in Next.js projects. +- Don't use `` elements in Next.js projects. +- Don't import next/document outside of pages/\_document.jsx in Next.js projects. +- Don't use the next/head module in pages/\_document.js on Next.js projects. + +### Testing Best Practices + +- Don't use export or module.exports in test files. +- Don't use focused tests. +- Make sure the assertion function, like expect, is placed inside an it() function call. +- Don't use disabled tests. + +## Common Tasks + +- `npx ultracite init` - Initialize Ultracite in your project +- `npx ultracite format` - Format and fix code automatically +- `npx ultracite lint` - Check for issues without fixing + +## Example: Error Handling + +```typescript +// ✅ Good: Comprehensive error handling +try { + const result = await fetchData(); + return { success: true, data: result }; +} catch (error) { + console.error("API call failed:", error); + return { success: false, error: error.message }; +} + +// ❌ Bad: Swallowing errors +try { + return await fetchData(); +} catch (e) { + console.log(e); +} +``` diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..13be308b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +.next +node_modules +dist +build +target +rust/wasm/pkg diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..c9590876 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "useTabs": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 187722fb..2fc093b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,14 +7,7 @@ "editor.formatOnSave": true, "editor.formatOnPaste": true, "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" + "source.fixAll.eslint": "explicit" }, - "emmet.showExpandedAbbreviation": "never", - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - } + "emmet.showExpandedAbbreviation": "never" } diff --git a/AGENTS.md b/AGENTS.md index d326ac5d..cd59a85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,17 +21,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a - Read components before using them. They may already apply classes, which affects what you need to pass and how to override them. -### TypeScript - -Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter: - -```tsx -// ❌ meaning depends on order; the shape can't evolve without touching every caller -function formatTime(seconds: number, fps: number) { ... } - -// ✅ each argument names itself; fields can be added, reordered, or made optional freely -function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... } -``` - -The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply. - diff --git a/apps/web/package.json b/apps/web/package.json index bbc3ad0a..90cd9fb5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,9 +9,9 @@ "start": "next start", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", - "lint": "biome check src/", - "lint:fix": "biome check src/ --write", - "format": "biome format src/ --write", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "format": "prettier src --write", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx index cd9c0242..7f7d8a87 100644 --- a/apps/web/src/actions/components/shortcuts-dialog.tsx +++ b/apps/web/src/actions/components/shortcuts-dialog.tsx @@ -51,10 +51,10 @@ export function ShortcutsDialog({ const keyString = getKeybindingString(e); if (keyString) { - const conflict = validateKeybinding( - keyString, - recordingShortcut.action, - ); + const conflict = validateKeybinding({ + key: keyString, + action: recordingShortcut.action, + }); if (conflict) { toast.error( `Key "${keyString}" is already bound to "${conflict.existingAction}"`, @@ -68,7 +68,10 @@ export function ShortcutsDialog({ removeKeybinding(key); } - updateKeybinding(keyString, recordingShortcut.action); + updateKeybinding({ + key: keyString, + action: recordingShortcut.action, + }); setIsRecording(false); setRecordingShortcut(null); diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts index 6f0ba700..986510ec 100644 --- a/apps/web/src/actions/definitions.ts +++ b/apps/web/src/actions/definitions.ts @@ -1,7 +1,4 @@ -import type { - KeybindingConfig, - ShortcutKey, -} from "@/actions/keybinding"; +import type { ShortcutKey } from "@/actions/keybinding"; import type { TActionWithOptionalArgs } from "./types"; export type TActionCategory = @@ -155,33 +152,36 @@ export const ACTIONS = { export type TAction = keyof typeof ACTIONS; -const ACTION_DEFAULT_SHORTCUTS = { - "toggle-play": ["space", "k"], - "seek-forward": ["l"], - "seek-backward": ["j"], - "frame-step-forward": ["right"], - "frame-step-backward": ["left"], - "jump-forward": ["shift+right"], - "jump-backward": ["shift+left"], - "goto-start": ["home", "enter"], - "goto-end": ["end"], - split: ["s"], - "split-left": ["q"], - "split-right": ["w"], - "delete-selected": ["backspace", "delete"], - "copy-selected": ["ctrl+c"], - "paste-copied": ["ctrl+v"], - "toggle-snapping": ["n"], - "select-all": ["ctrl+a"], - "cancel-interaction": ["escape"], - "duplicate-selected": ["ctrl+d"], - undo: ["ctrl+z"], - redo: ["ctrl+shift+z", "ctrl+y"], -} as const satisfies Partial>; +const ACTION_DEFAULT_SHORTCUTS = [ + ["toggle-play", ["space", "k"]], + ["seek-forward", ["l"]], + ["seek-backward", ["j"]], + ["frame-step-forward", ["right"]], + ["frame-step-backward", ["left"]], + ["jump-forward", ["shift+right"]], + ["jump-backward", ["shift+left"]], + ["goto-start", ["home", "enter"]], + ["goto-end", ["end"]], + ["split", ["s"]], + ["split-left", ["q"]], + ["split-right", ["w"]], + ["delete-selected", ["backspace", "delete"]], + ["copy-selected", ["ctrl+c"]], + ["paste-copied", ["ctrl+v"]], + ["toggle-snapping", ["n"]], + ["select-all", ["ctrl+a"]], + ["cancel-interaction", ["escape"]], + ["duplicate-selected", ["ctrl+d"]], + ["undo", ["ctrl+z"]], + ["redo", ["ctrl+shift+z", "ctrl+y"]], +] as const satisfies ReadonlyArray< + readonly [TActionWithOptionalArgs, readonly ShortcutKey[]] +>; -const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< - Record -> = ACTION_DEFAULT_SHORTCUTS; +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map< + TAction, + readonly ShortcutKey[] +>(ACTION_DEFAULT_SHORTCUTS); export function getActionDefinition({ action, @@ -190,18 +190,19 @@ export function getActionDefinition({ }): TActionDefinition { return { ...ACTIONS[action], - defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], + defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action), }; } -export function getDefaultShortcuts(): KeybindingConfig { - const shortcuts: KeybindingConfig = {}; +export function getDefaultShortcuts(): Map< + ShortcutKey, + TActionWithOptionalArgs +> { + const shortcuts = new Map(); - for (const [action, defaultShortcuts] of Object.entries( - ACTION_DEFAULT_SHORTCUTS, - ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) { + for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) { for (const shortcut of defaultShortcuts) { - shortcuts[shortcut] = action; + shortcuts.set(shortcut, action); } } diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts index 65ef4538..aeec2fd6 100644 --- a/apps/web/src/actions/keybinding.ts +++ b/apps/web/src/actions/keybinding.ts @@ -1,79 +1,43 @@ -import type { TActionWithOptionalArgs } from "./types"; - -/** - * Alt is also regarded as macOS OPTION (⌥) key - * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) - */ -export type ModifierKeys = - | "ctrl" - | "alt" - | "shift" - | "ctrl+shift" - | "alt+shift" - | "ctrl+alt" - | "ctrl+alt+shift"; - -export type Key = - | "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z" - | "0" - | "1" - | "2" - | "3" - | "4" - | "5" - | "6" - | "7" - | "8" - | "9" - | "up" - | "down" - | "left" - | "right" - | "/" - | "?" - | "." - | "enter" - | "tab" - | "space" - | "escape" - | "esc" - | "backspace" - | "delete" - | "home" - | "end"; -/* eslint-enable */ - -export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; -// Singular keybindings (these will be disabled when an input-ish area has been focused) -export type SingleCharacterShortcutKey = `${Key}`; - -export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; - -export type KeybindingConfig = { - [key in ShortcutKey]?: TActionWithOptionalArgs; -}; +import type { TActionWithOptionalArgs } from "./types"; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +const KEYS = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", "z", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "up", "down", "left", "right", + "/", "?", ".", + "enter", "tab", "space", "escape", "esc", + "backspace", "delete", "home", "end", +] as const; + +export type Key = (typeof KEYS)[number]; + +const KEY_SET: ReadonlySet = new Set(KEYS); + +export function isKey(value: string): value is Key { + return KEY_SET.has(value); +} + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; +}; diff --git a/apps/web/src/actions/keybindings-store.ts b/apps/web/src/actions/keybindings-store.ts index c0a1a5e2..8f344487 100644 --- a/apps/web/src/actions/keybindings-store.ts +++ b/apps/web/src/actions/keybindings-store.ts @@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions"; import { getDefaultShortcuts } from "@/actions"; import { isTypableDOMElement } from "@/utils/browser"; import { isAppleDevice } from "@/utils/platform"; -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; +import type { + Key, + KeybindingConfig, + ModifierKeys, + ShortcutKey, +} from "@/actions/keybinding"; +import { isKey } from "@/actions/keybinding"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; -const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); - export interface KeybindingConflict { key: ShortcutKey; existingAction: TActionWithOptionalArgs; @@ -18,38 +22,57 @@ export interface KeybindingConflict { } interface KeybindingsState { - keybindings: KeybindingConfig; + keybindings: Map; isCustomized: boolean; overlayDepth: number; openOverlayIds: string[]; isLoadingProject: boolean; isRecording: boolean; - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + updateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => void; removeKeybinding: (key: ShortcutKey) => void; resetToDefaults: () => void; importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; + exportKeybindings: () => Record; openOverlay: (overlayId: string) => void; closeOverlay: (overlayId: string) => void; setLoadingProject: (loading: boolean) => void; setIsRecording: (isRecording: boolean) => void; - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => KeybindingConflict | null; + validateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => KeybindingConflict | null; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } +type PersistedState = { + keybindings: Record; + isCustomized: boolean; +}; + function isDOMElement(element: EventTarget | null): element is HTMLElement { return element instanceof HTMLElement; } +function isPersistedState(value: unknown): value is PersistedState { + if (!value || typeof value !== "object") return false; + if (!("keybindings" in value) || !("isCustomized" in value)) return false; + const { keybindings, isCustomized } = value; + return ( + typeof keybindings === "object" && + keybindings !== null && + typeof isCustomized === "boolean" + ); +} + export const useKeybindingsStore = create()( persist( (set, get) => ({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, overlayDepth: 0, openOverlayIds: [], @@ -61,10 +84,9 @@ export const useKeybindingsStore = create()( const openOverlayIds = s.openOverlayIds.includes(overlayId) ? s.openOverlayIds : [...s.openOverlayIds, overlayId]; - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), closeOverlay: (overlayId) => @@ -72,35 +94,32 @@ export const useKeybindingsStore = create()( const openOverlayIds = s.openOverlayIds.filter( (id) => id !== overlayId, ); - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), setLoadingProject: (loading) => { set({ isLoadingProject: loading }); }, - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + updateKeybinding: ({ key, action }) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; - + const next = new Map(state.keybindings); + next.set(key, action); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); }, - removeKeybinding: (key: ShortcutKey) => { + removeKeybinding: (key) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; - + const next = new Map(state.keybindings); + next.delete(key); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); @@ -108,34 +127,35 @@ export const useKeybindingsStore = create()( resetToDefaults: () => { set({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, }); }, - importKeybindings: (config: KeybindingConfig) => { - for (const [key] of Object.entries(config)) { + importKeybindings: (config) => { + const next = new Map(); + for (const [key, action] of Object.entries(config)) { if (typeof key !== "string" || key.length === 0) { throw new Error(`Invalid key format: ${key}`); } + if (action !== undefined) { + // Public type's keys are `ShortcutKey`; trust the caller's typing. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + next.set(key as ShortcutKey, action); + } } set({ - keybindings: { ...config }, + keybindings: next, isCustomized: true, }); }, exportKeybindings: () => { - return get().keybindings; + return Object.fromEntries(get().keybindings); }, - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; - + validateKeybinding: ({ key, action }) => { + const existingAction = get().keybindings.get(key); if (existingAction && existingAction !== action) { return { key, @@ -143,33 +163,45 @@ export const useKeybindingsStore = create()( newAction: action, }; } - return null; }, - setIsRecording: (isRecording: boolean) => { + setIsRecording: (isRecording) => { set({ isRecording }); }, - getKeybindingsForAction: (action: TActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action, - ) as ShortcutKey[]; + getKeybindingsForAction: (action) => { + const result: ShortcutKey[] = []; + for (const [key, mapped] of get().keybindings) { + if (mapped === action) result.push(key); + } + return result; }, - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, + getKeybindingString: (ev) => generateKeybindingString(ev), }), { name: "opencut-keybindings", version: CURRENT_VERSION, - partialize: (state) => ({ - keybindings: state.keybindings, + partialize: (state): PersistedState => ({ + keybindings: Object.fromEntries(state.keybindings), isCustomized: state.isCustomized, }), migrate: (persisted, version) => runMigrations({ state: persisted, fromVersion: version }), + merge: (persisted, current) => { + if (!isPersistedState(persisted)) return current; + const entries = Object.entries(persisted.keybindings); + // Persistence boundary: keys are normalized by the migration chain. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const typedEntries = entries as Array< + [ShortcutKey, TActionWithOptionalArgs] + >; + return { + ...current, + keybindings: new Map(typedEntries), + isCustomized: persisted.isCustomized, + }; + }, }, ), ); @@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { if ( modifierKey === "shift" && isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) + isTypableDOMElement({ element: target }) ) { return null; } - return `${modifierKey}+${key}` as ShortcutKey; + return `${modifierKey}+${key}`; } - if ( - isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) - ) + if (isDOMElement(target) && isTypableDOMElement({ element: target })) { return null; + } - return `${key}` as ShortcutKey; + return key; } -function getPressedKey(ev: KeyboardEvent): string | null { - const key = (ev.key ?? "").toLowerCase(); +function getPressedKey(ev: KeyboardEvent): Key | null { + const raw = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; - if (code === "Space" || key === " " || key === "spacebar" || key === "space") + if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space") return "space"; - if (key.startsWith("arrow")) return key.slice(5); - - if (key === "escape") return "escape"; - if (key === "tab") return "tab"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; + if (raw === "arrowup") return "up"; + if (raw === "arrowdown") return "down"; + if (raw === "arrowleft") return "left"; + if (raw === "arrowright") return "right"; if (code.startsWith("Key")) { const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; + if (isKey(letter)) return letter; } - // Use physical key position for AZERTY and other non-QWERTY layouts + // Use physical key position for AZERTY and other non-QWERTY layouts. if (code.startsWith("Digit")) { const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; + if (isKey(digit)) return digit; } - const isDigit = key.length === 1 && key >= "0" && key <= "9"; - if (isDigit) return key; - - if (key === "/" || key === "." || key === "enter") return key; - + if (isKey(raw)) return raw; return null; } -function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey; + const alt = ev.altKey; + const shift = ev.shiftKey; - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); - - return activeModifier === "" ? null : activeModifier; + if (ctrl && alt && shift) return "ctrl+alt+shift"; + if (ctrl && alt) return "ctrl+alt"; + if (ctrl && shift) return "ctrl+shift"; + if (alt && shift) return "alt+shift"; + if (ctrl) return "ctrl"; + if (alt) return "alt"; + if (shift) return "shift"; + return null; } diff --git a/apps/web/src/actions/keybindings/__tests__/persistence.test.ts b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts new file mode 100644 index 00000000..bcccc6a4 --- /dev/null +++ b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts @@ -0,0 +1,144 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { + decodePersistedKeybindingsState, + migratePersistedKeybindingsState, + parseImportedKeybindings, + serializeKeybindingsState, +} from "../persistence"; + +describe("keybinding persistence", () => { + let warnSpy: ReturnType; + let originalWarn: typeof console.warn; + + beforeEach(() => { + originalWarn = console.warn; + warnSpy = mock(() => {}); + console.warn = warnSpy; + }); + + afterEach(() => { + console.warn = originalWarn; + }); + + test("migrates legacy persisted keybindings before decoding them", () => { + const migrated = migratePersistedKeybindingsState({ + state: { + keybindings: { + s: "split-selected", + "ctrl+v": "paste-selected", + }, + isCustomized: true, + }, + fromVersion: 2, + }); + + const decoded = decodePersistedKeybindingsState({ state: migrated }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected migrated keybindings to decode"); + + expect(decoded.isCustomized).toBe(true); + expect(decoded.keybindings.get("s")).toBe("split"); + expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied"); + expect(decoded.keybindings.get("escape")).toBe("cancel-interaction"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("filters invalid persisted entries at the boundary and warns", () => { + const decoded = decodePersistedKeybindingsState({ + state: { + keybindings: { + space: "toggle-play", + "shift+bogus": "toggle-play", + "ctrl+v": "not-an-action", + }, + isCustomized: false, + }, + }); + + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected persisted keybindings to decode"); + + expect(Array.from(decoded.keybindings.entries())).toEqual([ + ["space", "toggle-play"], + ]); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("returns null and warns when persisted shape is unrecognizable", () => { + const decoded = decodePersistedKeybindingsState({ state: "garbage" }); + expect(decoded).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("round-trips actions that have no default shortcut", () => { + // `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the + // defaults table; the validator must still accept it. + const serialized = serializeKeybindingsState({ + keybindings: new Map([ + ["space", "toggle-play"], + ["x", "stop-playback"], + ["b", "toggle-bookmark"], + ]), + isCustomized: true, + }); + + const decoded = decodePersistedKeybindingsState({ state: serialized }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected round-tripped keybindings"); + + expect(decoded.keybindings.get("space")).toBe("toggle-play"); + expect(decoded.keybindings.get("x")).toBe("stop-playback"); + expect(decoded.keybindings.get("b")).toBe("toggle-bookmark"); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe("parseImportedKeybindings", () => { + test("accepts a valid configuration", () => { + const result = parseImportedKeybindings({ + config: { + space: "toggle-play", + x: "stop-playback", + }, + }); + + expect(result.get("space")).toBe("toggle-play"); + expect(result.get("x")).toBe("stop-playback"); + }); + + test("throws on non-object input", () => { + expect(() => parseImportedKeybindings({ config: null })).toThrow( + /JSON object/, + ); + expect(() => parseImportedKeybindings({ config: [] })).toThrow( + /JSON object/, + ); + }); + + test("throws on non-string action values", () => { + expect(() => + parseImportedKeybindings({ config: { space: 42 } }), + ).toThrow(/expected string/); + }); + + test("throws on invalid shortcut keys", () => { + expect(() => + parseImportedKeybindings({ + config: { "shift+bogus": "toggle-play" }, + }), + ).toThrow(/shift\+bogus/); + }); + + test("throws on invalid actions", () => { + expect(() => + parseImportedKeybindings({ config: { space: "not-an-action" } }), + ).toThrow(/not-an-action/); + }); +}); diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts index 0171c5a5..81fd86a7 100644 --- a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts +++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts @@ -1,13 +1,8 @@ -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; -import type { TActionWithOptionalArgs } from "@/actions"; - -interface V2State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v2ToV3({ state }: { state: unknown }): unknown { - const v2 = state as V2State; + const v2 = getPersistedKeybindingsState({ state }); + if (!v2) return state; const renames: Record = { "split-selected": "split", @@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown { const migrated = { ...v2.keybindings }; for (const [key, action] of Object.entries(migrated)) { - if (action && renames[action]) { - migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + const renamedAction = action ? renames[action] : undefined; + if (renamedAction) { + migrated[key] = renamedAction; } } diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts index 8541071b..3724f61c 100644 --- a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts +++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts @@ -1,14 +1,8 @@ -import type { TActionWithOptionalArgs } from "@/actions"; -import type { ShortcutKey } from "@/actions/keybinding"; -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V3State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v3ToV4({ state }: { state: unknown }): unknown { - const v3 = state as V3State; + const v3 = getPersistedKeybindingsState({ state }); + if (!v3) return state; const renames: Record = { "paste-selected": "paste-copied", @@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown { const migrated = { ...v3.keybindings }; for (const [key, action] of Object.entries(migrated)) { - if (action && renames[action]) { - migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + const renamedAction = action ? renames[action] : undefined; + if (renamedAction) { + migrated[key] = renamedAction; } } diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts index f6937c39..bb60b044 100644 --- a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts +++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V4State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v4ToV5({ state }: { state: unknown }): unknown { - const v4 = state as V4State; + const v4 = getPersistedKeybindingsState({ state }); + if (!v4) return state; const keybindings = { ...v4.keybindings }; if (!keybindings.escape) { diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts index 09b44307..cb572aac 100644 --- a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts +++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V5State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v5ToV6({ state }: { state: unknown }): unknown { - const v5 = state as V5State; + const v5 = getPersistedKeybindingsState({ state }); + if (!v5) return state; const keybindings = { ...v5.keybindings }; if (keybindings.escape === "deselect-all") { diff --git a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts index 46a99ac1..9c05db23 100644 --- a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts +++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts @@ -1,16 +1,12 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V6State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v6ToV7({ state }: { state: unknown }): unknown { - const v6 = state as V6State; + const v6 = getPersistedKeybindingsState({ state }); + if (!v6) return state; const keybindings = { ...v6.keybindings }; - for (const key of Object.keys(keybindings) as Array) { - if (keybindings[key] === ("split-element" as never)) { + for (const [key, action] of Object.entries(keybindings)) { + if (action === "split-element") { keybindings[key] = "split"; } } diff --git a/apps/web/src/actions/keybindings/persisted-state.ts b/apps/web/src/actions/keybindings/persisted-state.ts new file mode 100644 index 00000000..57d83f6d --- /dev/null +++ b/apps/web/src/actions/keybindings/persisted-state.ts @@ -0,0 +1,37 @@ +export type PersistedKeybindingConfig = Record; + +export interface PersistedKeybindingsState { + keybindings: PersistedKeybindingConfig; + isCustomized: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getPersistedKeybindingsState({ + state, +}: { + state: unknown; +}): PersistedKeybindingsState | null { + if (!isRecord(state)) return null; + + const { keybindings, isCustomized } = state; + if (!isRecord(keybindings) || typeof isCustomized !== "boolean") { + return null; + } + + const normalizedKeybindings: PersistedKeybindingConfig = {}; + for (const [key, action] of Object.entries(keybindings)) { + if (action !== undefined && typeof action !== "string") { + return null; + } + + normalizedKeybindings[key] = action; + } + + return { + keybindings: normalizedKeybindings, + isCustomized, + }; +} diff --git a/apps/web/src/actions/keybindings/persistence.ts b/apps/web/src/actions/keybindings/persistence.ts new file mode 100644 index 00000000..ced44bd0 --- /dev/null +++ b/apps/web/src/actions/keybindings/persistence.ts @@ -0,0 +1,119 @@ +import type { ShortcutKey } from "@/actions/keybinding"; +import { isShortcutKey } from "@/actions/keybinding"; +import type { TActionWithOptionalArgs } from "@/actions"; +import { isActionWithOptionalArgs } from "@/actions"; +import { runMigrations } from "./migrations"; +import { + getPersistedKeybindingsState, + type PersistedKeybindingsState, +} from "./persisted-state"; + +export interface DecodedKeybindingsState { + keybindings: Map; + isCustomized: boolean; +} + +export function serializeKeybindingsState({ + keybindings, + isCustomized, +}: DecodedKeybindingsState): PersistedKeybindingsState { + return { + keybindings: Object.fromEntries(keybindings), + isCustomized, + }; +} + +export function migratePersistedKeybindingsState({ + state, + fromVersion, +}: { + state: unknown; + fromVersion: number; +}): unknown { + return runMigrations({ state, fromVersion }); +} + +/** + * Decode a persisted/migrated keybindings blob into the in-memory shape. + * + * Lossy by design: invalid entries are dropped and a warning is emitted, so the + * user falls back to (mostly) sensible defaults instead of a broken store. + * Returns `null` if the top-level shape is unrecognizable, in which case the + * caller should keep its current state. + */ +export function decodePersistedKeybindingsState({ + state, +}: { + state: unknown; +}): DecodedKeybindingsState | null { + const persisted = getPersistedKeybindingsState({ state }); + if (!persisted) { + console.warn( + "[keybindings] Persisted state has unexpected shape; keeping current keybindings.", + state, + ); + return null; + } + + const keybindings = new Map(); + const dropped: Array<{ key: string; action: string | undefined }> = []; + for (const [key, action] of Object.entries(persisted.keybindings)) { + if (action === undefined) continue; + if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) { + dropped.push({ key, action }); + continue; + } + + keybindings.set(key, action); + } + + if (dropped.length > 0) { + console.warn( + "[keybindings] Dropped invalid persisted entries:", + dropped, + ); + } + + return { + keybindings, + isCustomized: persisted.isCustomized, + }; +} + +/** + * Parse a user-supplied keybindings configuration (typically the output of + * `JSON.parse` on an imported file). + * + * Strict by design: throws on the first invalid entry so the caller can surface + * the failure to the user instead of silently producing a half-applied import. + * Accepts `unknown` because the input has already crossed a trust boundary. + */ +export function parseImportedKeybindings({ + config, +}: { + config: unknown; +}): Map { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new Error("Imported keybindings must be a JSON object"); + } + + const result = new Map(); + for (const [key, action] of Object.entries(config)) { + if (action === undefined) continue; + if (typeof action !== "string") { + throw new Error( + `Invalid action for "${key}": expected string, got ${typeof action}`, + ); + } + if (!isShortcutKey(key)) { + throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`); + } + if (!isActionWithOptionalArgs(action)) { + throw new Error( + `Invalid action for "${key}": ${JSON.stringify(action)}`, + ); + } + result.set(key, action); + } + return result; +} diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts index e69b3b9f..ece14eaf 100644 --- a/apps/web/src/actions/registry.ts +++ b/apps/web/src/actions/registry.ts @@ -11,6 +11,7 @@ import type { type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; const boundActions: Partial> = {}; +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function bindAction( action: A, handler: TActionFunc, @@ -24,6 +25,7 @@ export function bindAction( } } +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function unbindAction( action: A, handler: TActionFunc, @@ -52,6 +54,7 @@ type InvokeActionFunc = { ): void; }; +// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger. export const invokeAction: InvokeActionFunc = ( action: A, args?: TArgOfAction, diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts index 41258c7b..38627b95 100644 --- a/apps/web/src/actions/types.ts +++ b/apps/web/src/actions/types.ts @@ -1,44 +1,44 @@ -import type { MutableRefObject } from "react"; -import type { TAction } from "./definitions"; - -export type { TAction }; - -export type TActionArgsMap = { - "seek-forward": { seconds: number } | undefined; - "seek-backward": { seconds: number } | undefined; - "jump-forward": { seconds: number } | undefined; - "jump-backward": { seconds: number } | undefined; - "remove-media-asset": { projectId: string; assetId: string }; - "remove-media-assets": { projectId: string; assetIds: string[] }; -}; - -type TKeysWithValueUndefined = { - [K in keyof T]: undefined extends T[K] ? K : never; -}[keyof T]; - -export type TActionWithArgs = keyof TActionArgsMap; - -export type TActionWithOptionalArgs = - | TActionWithNoArgs - | TKeysWithValueUndefined; - -export type TActionWithNoArgs = Exclude; - -export type TArgOfAction = A extends TActionWithArgs - ? TActionArgsMap[A] - : undefined; - -export type TActionFunc = A extends TActionWithArgs - ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void - : (_?: undefined, trigger?: TInvocationTrigger) => void; - -export type TInvocationTrigger = "keypress" | "mouseclick"; - -export type TBoundActionList = { - [A in TAction]?: Array>; -}; - -export type TActionHandlerOptions = - | MutableRefObject - | boolean - | undefined; +import type { MutableRefObject } from "react"; +import type { TAction } from "./definitions"; + +export type { TAction }; + +export type TActionArgsMap = { + "seek-forward": { seconds: number } | undefined; + "seek-backward": { seconds: number } | undefined; + "jump-forward": { seconds: number } | undefined; + "jump-backward": { seconds: number } | undefined; + "remove-media-asset": { projectId: string; assetId: string }; + "remove-media-assets": { projectId: string; assetIds: string[] }; +}; + +type TKeysWithValueUndefined = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined; + +export type TActionWithNoArgs = Exclude; + +export type TArgOfAction = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc = A extends TActionWithArgs + ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array>; +}; + +export type TActionHandlerOptions = + | MutableRefObject + | boolean + | undefined; diff --git a/apps/web/src/actions/use-action-handler.ts b/apps/web/src/actions/use-action-handler.ts index a8bb0054..d4a37b50 100644 --- a/apps/web/src/actions/use-action-handler.ts +++ b/apps/web/src/actions/use-action-handler.ts @@ -8,6 +8,7 @@ import type { } from "@/actions"; import { bindAction, unbindAction } from "@/actions"; +// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive). export function useActionHandler( action: A, handler: TActionFunc, diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index 5e90a6a1..73300c6e 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useActionHandler } from "@/actions/use-action-handler"; import { useEditor } from "@/editor/use-editor"; @@ -25,6 +25,7 @@ import { clearActiveScope, type ScopeEntry, } from "@/selection/scope"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; export function useEditorActions() { const editor = useEditor(); @@ -36,47 +37,34 @@ export function useEditorActions() { const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); - const hasTimelineSelectionRef = useRef(false); - const clearTimelineSelectionRef = useRef(() => {}); - const clearTimelineActiveSelectionRef = useRef(() => {}); - const timelineScopeRef = useRef(null); const hasTimelineSelection = selectedElements.length > 0 || selectedKeyframes.length > 0 || selectedMaskPointSelection !== null; - - hasTimelineSelectionRef.current = hasTimelineSelection; - clearTimelineSelectionRef.current = () => { + const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection); + const clearTimelineSelectionRef = useCommittedRef(() => { editor.selection.clearSelection(); - }; - clearTimelineActiveSelectionRef.current = () => { + }); + const clearTimelineActiveSelectionRef = useCommittedRef(() => { editor.selection.clearMostSpecificSelection(); - }; - - if (!timelineScopeRef.current) { - timelineScopeRef.current = { - hasSelection: () => hasTimelineSelectionRef.current, - clear: () => { - clearTimelineSelectionRef.current(); - }, - clearActive: () => { - clearTimelineActiveSelectionRef.current(); - }, - }; - } + }); + const [timelineScope] = useState(() => ({ + hasSelection: () => hasTimelineSelectionRef.current, + clear: () => { + clearTimelineSelectionRef.current(); + }, + clearActive: () => { + clearTimelineActiveSelectionRef.current(); + }, + })); useEffect(() => { if (!hasTimelineSelection) { return; } - const timelineScope = timelineScopeRef.current; - if (!timelineScope) { - return; - } - return activateScope({ entry: timelineScope }); - }, [hasTimelineSelection]); + }, [hasTimelineSelection, timelineScope]); useActionHandler( "toggle-play", diff --git a/apps/web/src/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts index c129795c..e9d78982 100644 --- a/apps/web/src/actions/use-keybindings.ts +++ b/apps/web/src/actions/use-keybindings.ts @@ -33,7 +33,7 @@ export function useKeybindingsListener() { const isTextInput = activeElement instanceof HTMLElement && isTypableDOMElement({ element: activeElement }); - const boundAction = binding ? keybindings[binding] : undefined; + const boundAction = binding ? keybindings.get(binding) : undefined; if (normalizedKey === "escape" && isTextInput) { activeElement.blur(); diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts index 7681e664..89a17887 100644 --- a/apps/web/src/actions/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts @@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() { const { keybindings } = useKeybindingsStore(); const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; - const actionToKeys: Partial> = {}; + const actionToKeys = new Map(); - for (const [key, action] of Object.entries(keybindings) as Array< - [string, TActionWithOptionalArgs | undefined] - >) { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey({ key })); + for (const [key, action] of keybindings) { + const existing = actionToKeys.get(action); + if (existing) { + existing.push(formatKey({ key })); + } else { + actionToKeys.set(action, [formatKey({ key })]); } } - for (const [action, keys] of Object.entries(actionToKeys) as Array< - [TActionWithOptionalArgs, string[]] - >) { + const result: KeyboardShortcut[] = []; + for (const [action, keys] of actionToKeys) { const actionDef = ACTIONS[action]; - if (!actionDef) { - continue; - } + if (!actionDef) continue; result.push({ id: action, keys, diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/animation/property-registry.ts index aacb9972..bca9d82e 100644 --- a/apps/web/src/animation/property-registry.ts +++ b/apps/web/src/animation/property-registry.ts @@ -32,7 +32,11 @@ export interface AnimationPropertyDefinition { supportsElement: ({ element }: { element: TimelineElement }) => boolean; getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ + // Apply `value` to `element` for this property. Coerces the value through + // `coerceValue` and verifies element support; returns `element` unchanged + // if either fails. Cannot be bypassed — there is no kind-narrow `setValue` + // on the public surface, so callers can't apply an unvalidated value. + applyValue: ({ element, value, }: { @@ -94,7 +98,13 @@ function createNumberPropertyDefinition({ numericRange?: NumericSpec; supportsElement: AnimationPropertyDefinition["supportsElement"]; getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: number; + }) => TimelineElement; }): AnimationPropertyDefinition { return { kind: "number", @@ -102,12 +112,51 @@ function createNumberPropertyDefinition({ numericRanges: numericRange ? { value: numericRange } : undefined, supportsElement, getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, + coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }), + applyValue: ({ element, value }) => { + if (!supportsElement({ element })) { + return element; + } + const coerced = coerceNumberValue({ value, numericRange }); + if (coerced === null) { + return element; + } + return setValue({ element, value: coerced }); + }, + }; +} + +function createColorPropertyDefinition({ + supportsElement, + getValue, + setValue, +}: { + supportsElement: AnimationPropertyDefinition["supportsElement"]; + getValue: AnimationPropertyDefinition["getValue"]; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: string; + }) => TimelineElement; +}): AnimationPropertyDefinition { + return { + kind: "color", + defaultInterpolation: "linear", + supportsElement, + getValue, + coerceValue: ({ value }) => coerceColorValue({ value }), + applyValue: ({ element, value }) => { + if (!supportsElement({ element })) { + return element; + } + const coerced = coerceColorValue({ value }); + if (coerced === null) { + return element; + } + return setValue({ element, value: coerced }); + }, }; } @@ -126,7 +175,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< ...element, transform: { ...element.transform, - position: { ...element.transform.position, x: value as number }, + position: { ...element.transform.position, x: value }, }, } : element, @@ -142,7 +191,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< ...element, transform: { ...element.transform, - position: { ...element.transform.position, y: value as number }, + position: { ...element.transform.position, y: value }, }, } : element, @@ -156,7 +205,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< isVisualElement(element) ? { ...element, - transform: { ...element.transform, scaleX: value as number }, + transform: { ...element.transform, scaleX: value }, } : element, }), @@ -169,7 +218,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< isVisualElement(element) ? { ...element, - transform: { ...element.transform, scaleY: value as number }, + transform: { ...element.transform, scaleY: value }, } : element, }), @@ -182,7 +231,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< isVisualElement(element) ? { ...element, - transform: { ...element.transform, rotate: value as number }, + transform: { ...element.transform, rotate: value }, } : element, }), @@ -192,9 +241,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< getValue: ({ element }) => isVisualElement(element) ? element.opacity : null, setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, + isVisualElement(element) ? { ...element, opacity: value } : element, }), volume: createNumberPropertyDefinition({ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, @@ -202,36 +249,26 @@ const ANIMATION_PROPERTY_REGISTRY: Record< getValue: ({ element }) => canElementHaveAudio(element) ? element.volume ?? 0 : null, setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, + canElementHaveAudio(element) ? { ...element, volume: value } : element, }), - color: { - kind: "color", - defaultInterpolation: "linear", + color: createColorPropertyDefinition({ supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", + element.type === "text" ? { ...element, color: value } : element, + }), + "background.color": createColorPropertyDefinition({ supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), setValue: ({ element, value }) => element.type === "text" ? { ...element, - background: { ...element.background, color: value as string }, + background: { ...element.background, color: value }, } : element, - }, + }), "background.paddingX": createNumberPropertyDefinition({ numericRange: { min: 0, step: 1 }, supportsElement: ({ element }) => element.type === "text", @@ -243,7 +280,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, paddingX: value as number }, + background: { ...element.background, paddingX: value }, } : element, }), @@ -258,7 +295,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, paddingY: value as number }, + background: { ...element.background, paddingY: value }, } : element, }), @@ -273,7 +310,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, offsetX: value as number }, + background: { ...element.background, offsetX: value }, } : element, }), @@ -288,7 +325,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, offsetY: value as number }, + background: { ...element.background, offsetY: value }, } : element, }), @@ -307,7 +344,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, cornerRadius: value as number }, + background: { ...element.background, cornerRadius: value }, } : element, }), @@ -362,11 +399,7 @@ export function withElementBaseValueForProperty({ value: AnimationValue; }): TimelineElement { const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); + return definition.applyValue({ element, value }); } export function getDefaultInterpolationForProperty({ diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index 94e3f3e1..9728cbc7 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -190,7 +190,7 @@ export default function BrandPage() {
-

What's not allowed

+

What's not allowed

- You're responsible for how you use OpenCut and the content you create. - Don't use it for anything illegal in your jurisdiction. + You're responsible for how you use OpenCut and the content you + create. Don't use it for anything illegal in your jurisdiction.

@@ -127,8 +131,8 @@ export default function TermsPage() {

Service

OpenCut does not currently require an account. The service is provided - "as is" without warranties. While we strive for reliability, we can't - guarantee uninterrupted service. + "as is" without warranties. While we strive for + reliability, we can't guarantee uninterrupted service.

@@ -146,7 +150,7 @@ export default function TermsPage() {
GitHub @@ -161,12 +165,12 @@ export default function TermsPage() { OpenCut is provided free of charge. To the extent permitted by law:

    -
  • We're not liable for any loss of data or content
  • +
  • We're not liable for any loss of data or content
  • Projects are stored in your browser and may be lost if you clear browser data
  • -
  • We're not responsible for how you use the service
  • +
  • We're not responsible for how you use the service
  • Our liability is limited to the maximum extent allowed by law

@@ -180,7 +184,7 @@ export default function TermsPage() {

Service Changes

We may update OpenCut and these terms: