mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-01 20:32:20 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
46732fc76b
commit
a72aa1a594
@@ -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<string, Float32Array>();
|
||||
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 `<canvas>` element that fills its
|
||||
* containing row. Designed to be passed as the `background` prop of `<Row>`.
|
||||
*
|
||||
* - 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<HTMLCanvasElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(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;
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const isScrubbingTimelineRef = useRef(false);
|
||||
const scrubPointerIdRef = useRef<number | null>(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 ? (
|
||||
<RowWaveform videoUrl={videoUrl} videoDurationMs={videoDurationMs} />
|
||||
<RowWaveform peaks={peaks} videoDurationMs={videoDurationMs} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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<string, Float32Array>();
|
||||
|
||||
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<Float32Array | null>(() =>
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user