From a72aa1a594af5aeaa90c573e71195e930671ccb6 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:14:20 +0200 Subject: [PATCH] refactor: extract audio peak decoding into useAudioPeaks hook Move fetch/decode/cache logic out of RowWaveform into a standalone useAudioPeaks hook. RowWaveform is now a pure canvas renderer that accepts pre-computed peaks as a prop. TimelineEditor calls the hook and passes peaks down, skipping decoding entirely when waveform is off. Co-Authored-By: Claude Sonnet 4.6 --- .../video-editor/timeline/RowWaveform.tsx | 83 +---------------- .../video-editor/timeline/TimelineEditor.tsx | 4 +- src/hooks/useAudioPeaks.ts | 90 +++++++++++++++++++ 3 files changed, 97 insertions(+), 80 deletions(-) create mode 100644 src/hooks/useAudioPeaks.ts diff --git a/src/components/video-editor/timeline/RowWaveform.tsx b/src/components/video-editor/timeline/RowWaveform.tsx index 0ebce152..a77ee2c8 100644 --- a/src/components/video-editor/timeline/RowWaveform.tsx +++ b/src/components/video-editor/timeline/RowWaveform.tsx @@ -2,100 +2,25 @@ import { useTimelineContext } from "dnd-timeline"; import { useEffect, useRef, useState } from "react"; export interface RowWaveformProps { - videoUrl?: string; + /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */ + peaks: Float32Array | null; videoDurationMs: number; } -// Module-level cache keyed by URL — survives re-mounts within the same page session. -const peaksCache = new Map(); -let _audioCtx: AudioContext | null = null; - -function getAudioCtx(): AudioContext { - if (!_audioCtx) _audioCtx = new AudioContext(); - return _audioCtx; -} - -function computePeaks(audioBuffer: AudioBuffer): Float32Array { - const N = Math.min(24000, Math.ceil(audioBuffer.duration * 200)); - const nCh = audioBuffer.numberOfChannels; - const totalSamples = audioBuffer.length; - const blockSize = totalSamples / N; - const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] - - const channels: Float32Array[] = []; - for (let c = 0; c < nCh; c++) channels.push(audioBuffer.getChannelData(c)); - - for (let i = 0; i < N; i++) { - const start = Math.floor(i * blockSize); - const end = Math.floor((i + 1) * blockSize); - let minVal = 0; - let maxVal = 0; - for (let j = start; j < end; j++) { - let sample = 0; - for (let c = 0; c < nCh; c++) sample += channels[c][j]; - sample /= nCh; - if (sample < minVal) minVal = sample; - if (sample > maxVal) maxVal = sample; - } - peaks[i * 2] = minVal; - peaks[i * 2 + 1] = maxVal; - } - - return peaks; -} - /** * Renders a faint audio waveform on a `` element that fills its * containing row. Designed to be passed as the `background` prop of ``. * - * - Decodes audio from `videoUrl` once per URL (module-level cache). + * - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`). * - Redraws whenever the timeline zoom/pan range changes. * - `pointer-events: none` throughout — never blocks drag-to-create interactions. - * - Silent fallback when the file has no audio track. */ -export default function RowWaveform({ videoUrl, videoDurationMs }: RowWaveformProps) { +export default function RowWaveform({ peaks, videoDurationMs }: RowWaveformProps) { const { range } = useTimelineContext(); const canvasRef = useRef(null); const wrapperRef = useRef(null); - const [peaks, setPeaks] = useState(null); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); - // Decode audio once per videoUrl, store peaks in module-level cache. - useEffect(() => { - if (!videoUrl) { - setPeaks(null); - return; - } - - const cached = peaksCache.get(videoUrl); - if (cached) { - setPeaks(cached); - return; - } - - let cancelled = false; - - (async () => { - try { - const response = await fetch(videoUrl); - if (cancelled) return; - const arrayBuffer = await response.arrayBuffer(); - if (cancelled) return; - const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); - if (cancelled) return; - const p = computePeaks(audioBuffer); - peaksCache.set(videoUrl, p); - setPeaks(p); - } catch { - // No audio track or unsupported format — silent degradation. - } - })(); - - return () => { - cancelled = true; - }; - }, [videoUrl]); - // Track container dimensions via ResizeObserver. useEffect(() => { const el = wrapperRef.current; diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index d0026610..3bb420d8 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { useAudioPeaks } from "@/hooks/useAudioPeaks"; import { matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; @@ -597,6 +598,7 @@ function Timeline({ const localTimelineRef = useRef(null); const isScrubbingTimelineRef = useRef(false); const scrubPointerIdRef = useRef(null); + const peaks = useAudioPeaks(showTrimWaveform ? videoUrl : undefined); const setRefs = useCallback( (node: HTMLDivElement | null) => { @@ -801,7 +803,7 @@ function Timeline({ hint={t("hints.pressTrim")} background={ showTrimWaveform ? ( - + ) : undefined } > diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts new file mode 100644 index 00000000..206ad1d1 --- /dev/null +++ b/src/hooks/useAudioPeaks.ts @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; + +// Module-level cache keyed by URL — survives re-mounts within the same page session. +const peaksCache = new Map(); + +let _audioCtx: AudioContext | null = null; +function getAudioCtx(): AudioContext { + if (!_audioCtx) _audioCtx = new AudioContext(); + return _audioCtx; +} + +function computePeaks(audioBuffer: AudioBuffer): Float32Array { + const N = Math.min(24000, Math.ceil(audioBuffer.duration * 200)); + const nCh = audioBuffer.numberOfChannels; + const totalSamples = audioBuffer.length; + const blockSize = totalSamples / N; + const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] + + const channels: Float32Array[] = []; + for (let c = 0; c < nCh; c++) channels.push(audioBuffer.getChannelData(c)); + + for (let i = 0; i < N; i++) { + const start = Math.floor(i * blockSize); + const end = Math.floor((i + 1) * blockSize); + let minVal = 0; + let maxVal = 0; + for (let j = start; j < end; j++) { + let sample = 0; + for (let c = 0; c < nCh; c++) sample += channels[c][j]; + sample /= nCh; + if (sample < minVal) minVal = sample; + if (sample > maxVal) maxVal = sample; + } + peaks[i * 2] = minVal; + peaks[i * 2 + 1] = maxVal; + } + + return peaks; +} + +/** + * Decodes audio from `videoUrl` and returns a Float32Array of paired + * [min, max] peak values (length = 2 * N blocks). Returns `null` while + * decoding is in progress, and stays `null` when the file has no audio + * track or decoding fails (silent degradation). + * + * Results are cached at module scope by URL so re-mounts are free. + */ +export function useAudioPeaks(videoUrl?: string): Float32Array | null { + const [peaks, setPeaks] = useState(() => + videoUrl ? (peaksCache.get(videoUrl) ?? null) : null, + ); + + useEffect(() => { + if (!videoUrl) { + setPeaks(null); + return; + } + + const cached = peaksCache.get(videoUrl); + if (cached) { + setPeaks(cached); + return; + } + + let cancelled = false; + + (async () => { + try { + const response = await fetch(videoUrl); + if (cancelled) return; + const arrayBuffer = await response.arrayBuffer(); + if (cancelled) return; + const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); + if (cancelled) return; + const p = computePeaks(audioBuffer); + peaksCache.set(videoUrl, p); + setPeaks(p); + } catch { + // No audio track or unsupported format — silent degradation. + } + })(); + + return () => { + cancelled = true; + }; + }, [videoUrl]); + + return peaks; +}