diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index 051b671b0f..25fe95a44a 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -9,10 +9,10 @@ import { createMemo, createRoot, createSignal, - For, Index, Match, onCleanup, + onMount, Show, Switch, } from "solid-js"; @@ -44,9 +44,12 @@ function gainToScale(gain?: number) { return Math.max(0, 1 + value / -WAVEFORM_MIN_DB); } +const MAX_WAVEFORM_SAMPLES = 6000; + function createWaveformPath( segment: { start: number; end: number }, - waveform?: number[], + waveform: number[] | undefined, + targetSamples: number, ) { if (typeof Path2D === "undefined") return; if (!waveform || waveform.length === 0) return; @@ -54,10 +57,20 @@ function createWaveformPath( const duration = Math.max(segment.end - segment.start, WAVEFORM_SAMPLE_STEP); if (!Number.isFinite(duration) || duration <= 0) return; + const nativeSamples = Math.ceil(duration / WAVEFORM_SAMPLE_STEP) + 1; + const numSamples = Math.min( + Math.max(targetSamples, 50), + MAX_WAVEFORM_SAMPLES, + nativeSamples, + ); + + const timeStep = duration / numSamples; + const path = new Path2D(); path.moveTo(0, 1); - const amplitudeAt = (index: number) => { + const amplitudeAt = (time: number) => { + const index = Math.floor(time * 10); const sample = waveform[index]; const db = typeof sample === "number" && Number.isFinite(sample) @@ -70,17 +83,13 @@ function createWaveformPath( const controlStep = Math.min(WAVEFORM_CONTROL_STEP / duration, 0.25); - for ( - let time = segment.start; - time <= segment.end + WAVEFORM_SAMPLE_STEP; - time += WAVEFORM_SAMPLE_STEP - ) { - const index = Math.floor(time * 10); - const normalizedX = (index / 10 - segment.start) / duration; - const prevX = - (index / 10 - WAVEFORM_SAMPLE_STEP - segment.start) / duration; - const y = 1 - amplitudeAt(index); - const prevY = 1 - amplitudeAt(index - 1); + for (let i = 0; i <= numSamples; i++) { + const time = segment.start + i * timeStep; + const normalizedX = (time - segment.start) / duration; + const prevTime = time - timeStep; + const prevX = Math.max(0, (prevTime - segment.start) / duration); + const y = 1 - amplitudeAt(time); + const prevY = 1 - amplitudeAt(prevTime); const cpX1 = prevX + controlStep / 2; const cpX2 = normalizedX - controlStep / 2; path.bezierCurveTo(cpX1, prevY, cpX2, y, normalizedX, y); @@ -108,41 +117,115 @@ function formatTime(totalSeconds: number): string { } } +const MAX_CANVAS_WIDTH = 2000; +const SAMPLES_PER_PIXEL = 2; + function WaveformCanvas(props: { systemWaveform?: number[]; micWaveform?: number[]; segment: { start: number; end: number }; + segmentOffset: number; }) { - const { project } = useEditorContext(); + const { project, editorState } = useEditorContext(); const { width } = useSegmentContext(); - const segmentRange = createMemo(() => ({ - start: props.segment.start, - end: props.segment.end, - })); - const micPath = createMemo(() => - createWaveformPath(segmentRange(), props.micWaveform), - ); - const systemPath = createMemo(() => - createWaveformPath(segmentRange(), props.systemWaveform), - ); + const { timelineBounds } = useTimelineContext(); let canvas: HTMLCanvasElement | undefined; + let rafId: number | null = null; + let lastRenderKey = ""; - createEffect(() => { + const renderCanvas = () => { + rafId = null; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - const canvasWidth = Math.max(width(), 1); + const segmentDuration = props.segment.end - props.segment.start; + const fullSegmentWidth = width(); + + if (fullSegmentWidth < 1 || segmentDuration <= 0) { + return; + } + + const useVirtualization = fullSegmentWidth > MAX_CANVAS_WIDTH; + + let canvasWidth: number; + let leftOffsetPx: number; + let renderWidth: number; + let renderSegment: { start: number; end: number }; + + if (useVirtualization) { + const viewportWidth = timelineBounds.width ?? 800; + const transform = editorState.timeline.transform; + const viewStart = transform.position; + const viewEnd = viewStart + transform.zoom; + + const segStart = props.segmentOffset; + const segEnd = segStart + segmentDuration; + + const visibleStart = Math.max(viewStart, segStart); + const visibleEnd = Math.min(viewEnd, segEnd); + + if (visibleEnd <= visibleStart) { + canvas.width = 1; + canvas.style.left = "0px"; + canvas.style.width = "1px"; + return; + } + + const visibleStartInSegment = visibleStart - segStart; + const visibleEndInSegment = visibleEnd - segStart; + + const pxPerSec = fullSegmentWidth / segmentDuration; + const visibleWidthPx = Math.min( + (visibleEndInSegment - visibleStartInSegment) * pxPerSec, + viewportWidth + 200, + ); + + canvasWidth = Math.min( + Math.max(Math.ceil(visibleWidthPx), 1), + MAX_CANVAS_WIDTH, + ); + leftOffsetPx = visibleStartInSegment * pxPerSec; + renderWidth = visibleWidthPx; + renderSegment = { + start: props.segment.start + visibleStartInSegment, + end: props.segment.start + visibleEndInSegment, + }; + } else { + canvasWidth = Math.max(Math.ceil(fullSegmentWidth), 1); + leftOffsetPx = 0; + renderWidth = fullSegmentWidth; + renderSegment = { + start: props.segment.start, + end: props.segment.end, + }; + } + + const renderKey = `${canvasWidth}-${renderSegment.start.toFixed(2)}-${renderSegment.end.toFixed(2)}`; + if (renderKey === lastRenderKey) { + return; + } + lastRenderKey = renderKey; + canvas.width = canvasWidth; + canvas.style.left = `${leftOffsetPx}px`; + canvas.style.width = `${renderWidth}px`; + const canvasHeight = canvas.height; ctx.clearRect(0, 0, canvasWidth, canvasHeight); - const drawPath = ( - path: Path2D | undefined, + const numSamples = Math.min( + Math.ceil(canvasWidth * SAMPLES_PER_PIXEL), + MAX_WAVEFORM_SAMPLES, + ); + + const drawWaveform = ( + waveform: number[] | undefined, color: string, gain?: number, ) => { + const path = createWaveformPath(renderSegment, waveform, numSamples); if (!path) return; const scale = gainToScale(gain); if (scale <= 0) return; @@ -156,8 +239,50 @@ function WaveformCanvas(props: { ctx.restore(); }; - drawPath(micPath(), "rgba(255,255,255,0.4)", project.audio.micVolumeDb); - drawPath(systemPath(), "rgba(255,150,0,0.5)", project.audio.systemVolumeDb); + drawWaveform( + props.micWaveform, + "rgba(255,255,255,0.4)", + project.audio.micVolumeDb, + ); + drawWaveform( + props.systemWaveform, + "rgba(255,150,0,0.5)", + project.audio.systemVolumeDb, + ); + }; + + createEffect(() => { + width(); + timelineBounds.width; + editorState.timeline.transform.position; + editorState.timeline.transform.zoom; + props.segment.start; + props.segment.end; + props.micWaveform; + props.systemWaveform; + project.audio.micVolumeDb; + project.audio.systemVolumeDb; + + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(renderCanvas); + }); + + onMount(() => { + setTimeout(() => { + lastRenderKey = ""; + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(renderCanvas); + }, 300); + }); + + onCleanup(() => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } }); return ( @@ -165,7 +290,8 @@ function WaveformCanvas(props: { ref={(el) => { canvas = el; }} - class="absolute inset-0 w-full h-full pointer-events-none" + class="absolute top-0 h-full pointer-events-none" + style={{ left: "0px" }} height={CANVAS_HEIGHT} /> ); @@ -511,6 +637,7 @@ export function ClipTrack( micWaveform={micWaveform()} systemWaveform={systemAudioWaveform()} segment={segment()} + segmentOffset={prevDuration()} /> )} @@ -764,35 +891,40 @@ function Markings(props: { segment: TimelineSegment; prevDuration: number }) { const { editorState } = useEditorContext(); const { secsPerPixel, markingResolution } = useTimelineContext(); - const markings = () => { - const resolution = markingResolution(); + const transform = () => editorState.timeline.transform; - const { transform } = editorState.timeline; + const markingParams = () => { + const resolution = markingResolution(); const visibleMin = - transform.position - props.prevDuration + props.segment.start; - const visibleMax = visibleMin + transform.zoom; - + transform().position - props.prevDuration + props.segment.start; + const visibleMax = visibleMin + transform().zoom; const start = Math.floor(visibleMin / resolution); + const count = Math.ceil(visibleMax / resolution) - start; + return { resolution, start, count }; + }; - return Array.from( - { length: Math.ceil(visibleMax / resolution) - start }, - (_, i) => (start + i) * resolution, - ); + const getMarkingTime = (index: number) => { + const { resolution, start } = markingParams(); + return (start + index) * resolution; }; return ( - - {(marking) => ( -
- )} - + + {(_, index) => { + const marking = () => getMarkingTime(index); + const translateX = () => + (marking() - props.segment.start) / secsPerPixel(); + + return ( +
+ ); + }} + ); } diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index a88b96fd24..8aacea955f 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -3,10 +3,9 @@ import { createEventListener } from "@solid-primitives/event-listener"; import { platform } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { - batch, createRoot, createSignal, - For, + Index, type JSX, onMount, Show, @@ -216,6 +215,57 @@ export function Timeline() { let maskSegmentDragState = { type: "idle" } as MaskSegmentDragState; let textSegmentDragState = { type: "idle" } as TextSegmentDragState; + let pendingZoomDelta = 0; + let pendingZoomOrigin: number | null = null; + let zoomRafId: number | null = null; + + let pendingScrollDelta = 0; + let scrollRafId: number | null = null; + + function flushPendingZoom() { + if (pendingZoomDelta === 0 || pendingZoomOrigin === null) { + zoomRafId = null; + return; + } + + const newZoom = transform().zoom + pendingZoomDelta; + transform().updateZoom(newZoom, pendingZoomOrigin); + + pendingZoomDelta = 0; + pendingZoomOrigin = null; + zoomRafId = null; + } + + function flushPendingScroll() { + if (pendingScrollDelta === 0) { + scrollRafId = null; + return; + } + + const newPosition = transform().position + pendingScrollDelta; + transform().setPosition(newPosition); + + pendingScrollDelta = 0; + scrollRafId = null; + } + + function scheduleZoomUpdate(delta: number, origin: number) { + pendingZoomDelta += delta; + pendingZoomOrigin = origin; + + if (zoomRafId === null) { + zoomRafId = requestAnimationFrame(flushPendingZoom); + } + } + + function scheduleScrollUpdate(delta: number) { + pendingScrollDelta += delta; + + if (scrollRafId === null) { + scrollRafId = requestAnimationFrame(flushPendingScroll); + } + } + async function handleUpdatePlayhead(e: MouseEvent) { const { left } = timelineBounds; if ( @@ -396,39 +446,22 @@ export function Timeline() { setEditorState("previewTime", null); }} onWheel={(e) => { - // pinch zoom or ctrl + scroll if (e.ctrlKey) { - batch(() => { - const zoomDelta = (e.deltaY * Math.sqrt(transform().zoom)) / 30; - - const newZoom = transform().zoom + zoomDelta; - - transform().updateZoom( - newZoom, - editorState.previewTime ?? editorState.playbackTime, - ); - }); - } - // scroll - else { + const zoomDelta = (e.deltaY * Math.sqrt(transform().zoom)) / 30; + const origin = editorState.previewTime ?? editorState.playbackTime; + scheduleZoomUpdate(zoomDelta, origin); + } else { let delta: number = 0; - // Prioritize horizontal scrolling for touchpads - // For touchpads, both deltaX and deltaY can be used - // If deltaX is significant, use it (horizontal scrolling) if (Math.abs(e.deltaX) > Math.abs(e.deltaY) * 0.5) { delta = e.deltaX; - } - // Otherwise use platform-specific defaults - else if (platform() === "macos") { + } else if (platform() === "macos") { delta = e.shiftKey ? e.deltaX : e.deltaY; } else { delta = e.deltaY; } - const newPosition = transform().position + secsPerPixel() * delta; - - transform().setPosition(newPosition); + scheduleScrollUpdate(secsPerPixel() * delta); } }} > @@ -564,40 +597,44 @@ function TimelineMarkings() { const { secsPerPixel, markingResolution } = useTimelineContext(); const transform = () => editorState.timeline.transform; - const timelineMarkings = () => { - const diff = transform().position % markingResolution(); + const markingCount = () => + Math.ceil(2 + (transform().zoom + 5) / markingResolution()); - return Array.from( - { length: 2 + (transform().zoom + 5) / markingResolution() }, - (_, i) => transform().position - diff + (i + 0) * markingResolution(), - ); - }; + const markingOffset = () => transform().position % markingResolution(); + + const getMarkingTime = (index: number) => + transform().position - markingOffset() + index * markingResolution(); return (
- - {(second) => ( - 0}> + + {(_, index) => { + const second = () => getMarkingTime(index); + const isVisible = () => second() > 0; + const showLabel = () => second() % 1 === 0; + const translateX = () => + (second() - transform().position) / secsPerPixel() - 1; + + return (
- +
- {formatTime(second)} + {formatTime(second())}
-
- )} -
+ ); + }} +
); }