diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/components/audio-waveform.tsx | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/components/audio-waveform.tsx')
| -rw-r--r-- | apps/web/src/timeline/components/audio-waveform.tsx | 345 |
1 files changed, 345 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx new file mode 100644 index 00000000..7777bc09 --- /dev/null +++ b/apps/web/src/timeline/components/audio-waveform.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useResizeObserver } from "@/hooks/use-resize-observer"; +import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme"; +import { + buildWaveformSampleBuckets, + sampleSourceWaveformSummary, + type SourceWaveformSummary, +} from "@/media/waveform-summary"; +import type { RetimeConfig } from "@/timeline"; +import { getBarFractionFromOutputAmplitude } from "@/timeline/audio-display"; +import { waveformCache } from "@/services/waveform-cache/service"; +import { findScrollParent } from "@/utils/browser"; +import { cn } from "@/utils/ui"; + +const BAR_WIDTH = 1; +const BAR_GAP = 1; +const BAR_STEP = BAR_WIDTH + BAR_GAP; +const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)"; +export const WAVEFORM_GAIN_SAMPLE_COUNT = 200; + +function sampleGainAtClipTime({ + samples, + clipTimeSec, + clipDurationSec, +}: { + samples: number[]; + clipTimeSec: number; + clipDurationSec: number; +}): number { + if (samples.length === 0 || clipDurationSec <= 0) { + return 1; + } + + const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec)); + const rawIndex = progress * (samples.length - 1); + const lo = Math.floor(rawIndex); + const hi = Math.min(samples.length - 1, lo + 1); + return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo); +} + +interface AudioWaveformProps { + sourceKey: string; + sourceFile?: File; + audioUrl?: string; + audioBuffer?: AudioBuffer; + gainSamples?: number[]; + pixelsPerSecond: number; + clipDurationSec: number; + retime?: RetimeConfig; + sourceStartSec: number; + color?: string; + burnColor?: string; + className?: string; +} + +export function AudioWaveform({ + sourceKey, + sourceFile, + audioUrl, + audioBuffer, + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + color = TIMELINE_AUDIO_WAVEFORM_COLOR, + burnColor = WAVEFORM_BURN_COLOR, + className = "", +}: AudioWaveformProps) { + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const summaryRef = useRef<SourceWaveformSummary | null>(null); + const gainSamplesRef = useRef<number[] | undefined>(gainSamples); + const pixelsPerSecondRef = useRef<number>(pixelsPerSecond); + const clipDurationSecRef = useRef<number>(clipDurationSec); + const retimeRef = useRef<RetimeConfig | undefined>(retime); + const sourceStartSecRef = useRef<number>(sourceStartSec); + const scrollParentRef = useRef<HTMLElement | null>(null); + const heightRef = useRef<number>(0); + const lastRenderSignatureRef = useRef<string | null>(null); + + gainSamplesRef.current = gainSamples; + pixelsPerSecondRef.current = pixelsPerSecond; + clipDurationSecRef.current = clipDurationSec; + retimeRef.current = retime; + sourceStartSecRef.current = sourceStartSec; + + const clearCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + lastRenderSignatureRef.current = null; + }, []); + + const drawVisible = useCallback(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + const summary = summaryRef.current; + const height = heightRef.current; + + if (!container || !canvas || !summary || height <= 0) { + clearCanvas(); + return; + } + + const containerRect = container.getBoundingClientRect(); + const elementWidth = containerRect.width; + if (elementWidth <= 0) { + clearCanvas(); + return; + } + + const scrollParent = scrollParentRef.current; + + let clipLeft: number; + let clipRight: number; + + if (scrollParent) { + const parentRect = scrollParent.getBoundingClientRect(); + clipLeft = Math.max(0, parentRect.left - containerRect.left); + clipRight = Math.min(elementWidth, parentRect.right - containerRect.left); + } else { + clipLeft = Math.max(0, -containerRect.left); + clipRight = Math.min( + elementWidth, + window.innerWidth - containerRect.left, + ); + } + + const visibleWidth = clipRight - clipLeft; + if (visibleWidth <= 0) { + clearCanvas(); + return; + } + + const dpr = window.devicePixelRatio || 1; + const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr)); + const canvasH = Math.max(1, Math.round(height * dpr)); + const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); + const pixelsPerSecondValue = pixelsPerSecondRef.current; + const clipDurationSecValue = clipDurationSecRef.current; + const samples = gainSamplesRef.current; + const renderSignature = JSON.stringify({ + elementWidth, + clipLeft, + clipRight, + visibleWidth, + canvasW, + canvasH, + barCount, + dpr, + clipDurationSec: clipDurationSecValue, + sourceStartSec: sourceStartSecRef.current, + pixelsPerSecond: pixelsPerSecondValue, + retime: retimeRef.current ?? null, + summarySourceKey: summary.sourceKey, + summarySampleRate: summary.sampleRate, + summaryTotalSamples: summary.totalSamples, + summaryBucketSize: summary.bucketSize, + gainSamples: samples ?? null, + color, + burnColor, + }); + if (lastRenderSignatureRef.current === renderSignature) { + return; + } + lastRenderSignatureRef.current = renderSignature; + + canvas.width = canvasW; + canvas.height = canvasH; + canvas.style.width = `${visibleWidth}px`; + canvas.style.height = `${height}px`; + canvas.style.left = `${clipLeft}px`; + + const backingScaleX = dpr; + const backingScaleY = canvasH / height; + + const sampleBuckets = buildWaveformSampleBuckets({ + clipLeftPx: clipLeft, + clipRightPx: clipRight, + barCount, + pixelsPerSecond: pixelsPerSecondValue, + clipDurationSec: clipDurationSecValue, + sourceStartSec: sourceStartSecRef.current, + retime: retimeRef.current, + sampleRate: summary.sampleRate, + maxSampleExclusive: summary.totalSamples, + barStepPx: BAR_STEP, + }); + const amplitudes = sampleSourceWaveformSummary({ + summary, + buckets: sampleBuckets, + }); + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvasW, canvasH); + + const clipBottom = canvasH; + + for (let i = 0; i < barCount; i++) { + const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5; + const clipCenterSec = Math.max( + 0, + Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue), + ); + const gain = + samples != null + ? sampleGainAtClipTime({ + samples, + clipTimeSec: clipCenterSec, + clipDurationSec: clipDurationSecValue, + }) + : 1; + const amplitude = Math.max(0, amplitudes[i] ?? 0); + const outputAmplitude = amplitude * Math.max(0, gain); + const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude }); + const barH = fraction > 0 ? Math.max(1, fraction * height) : 0; + if (barH <= 0) { + continue; + } + + const barLeft = i * BAR_STEP; + const barRight = barLeft + BAR_WIDTH; + const deviceLeft = Math.round(barLeft * backingScaleX); + const deviceRight = Math.max( + deviceLeft + 1, + Math.round(barRight * backingScaleX), + ); + const deviceTop = Math.round((height - barH) * backingScaleY); + const deviceHeight = Math.max(1, clipBottom - deviceTop); + + ctx.fillStyle = color; + ctx.fillRect( + deviceLeft, + deviceTop, + deviceRight - deviceLeft, + deviceHeight, + ); + + if (outputAmplitude > 1) { + const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY)); + ctx.fillStyle = burnColor; + ctx.fillRect( + deviceLeft, + deviceTop, + deviceRight - deviceLeft, + burnHeight, + ); + } + } + }, [burnColor, clearCanvas, color]); + + useEffect(() => { + let isCancelled = false; + summaryRef.current = null; + clearCanvas(); + + void waveformCache + .getSourceSummary({ + sourceKey, + audioBuffer, + sourceFile, + audioUrl, + }) + .then((summary) => { + if (isCancelled) { + return; + } + summaryRef.current = summary; + drawVisible(); + }) + .catch(() => { + // Waveform loading failed (e.g. corrupt file, unsupported format). + // Fail silently — a missing waveform is preferable to an error state. + if (!isCancelled) { + clearCanvas(); + } + }); + + return () => { + isCancelled = true; + }; + }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change. + useLayoutEffect(() => { + drawVisible(); + }, [ + drawVisible, + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + ]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + scrollParentRef.current = findScrollParent({ element: container }); + const scrollParent = scrollParentRef.current; + if (!scrollParent) { + return; + } + + const handleScroll = () => { + drawVisible(); + }; + scrollParent.addEventListener("scroll", handleScroll, { passive: true }); + return () => scrollParent.removeEventListener("scroll", handleScroll); + }, [drawVisible]); + + const onResize = useCallback( + (entry: ResizeObserverEntry) => { + heightRef.current = entry.contentRect.height; + drawVisible(); + }, + [drawVisible], + ); + + useResizeObserver({ ref: containerRef, onResize }); + + return ( + <div ref={containerRef} className={cn("relative size-full", className)}> + <canvas ref={canvasRef} className="absolute bottom-0" /> + </div> + ); +} |
