(null);
+ const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
+
+ // Observe the canvas itself — Row's `relative overflow-hidden` parent
+ // makes it fill the row exactly, so no wrapper div is needed.
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ro = new ResizeObserver((entries) => {
+ const { width, height } = entries[0].contentRect;
+ setCanvasSize({ w: width, h: height });
+ });
+ ro.observe(canvas);
+ return () => ro.disconnect();
+ }, []);
+
+ // Redraw whenever peaks, range, or canvas size changes.
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = Math.round(canvasSize.w * dpr);
+ canvas.height = Math.round(canvasSize.h * dpr);
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.scale(dpr, dpr);
+ ctx.clearRect(0, 0, canvasSize.w, canvasSize.h);
+
+ if (!peaks || peaks.length === 0) return;
+
+ const W = canvasSize.w;
+ const H = canvasSize.h;
+ const mid = H / 2;
+ const amp = mid * 0.9;
+ const rangeMs = range.end - range.start;
+ if (rangeMs <= 0 || videoDurationMs <= 0) return;
+
+ const N = peaks.length / 2;
+
+ ctx.beginPath();
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
+ ctx.lineWidth = 1;
+
+ for (let x = 0; x < W; x++) {
+ const startMs = range.start + (x / W) * rangeMs;
+ const endMs = range.start + ((x + 1) / W) * rangeMs;
+ const lo = Math.max(0, Math.floor((startMs / videoDurationMs) * N));
+ const hi = Math.min(N - 1, Math.ceil((endMs / videoDurationMs) * N));
+
+ let minVal = 0;
+ let maxVal = 0;
+ for (let i = lo; i <= hi; i++) {
+ const mn = peaks[i * 2];
+ const mx = peaks[i * 2 + 1];
+ if (mn < minVal) minVal = mn;
+ if (mx > maxVal) maxVal = mx;
+ }
+
+ ctx.moveTo(x + 0.5, mid - maxVal * amp);
+ ctx.lineTo(x + 0.5, mid - minVal * amp);
+ }
+
+ ctx.stroke();
+ }, [peaks, range, canvasSize, videoDurationMs]);
+
+ return ;
+}
diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx
index 77fb52de..17a59e83 100644
--- a/src/components/video-editor/timeline/Row.tsx
+++ b/src/components/video-editor/timeline/Row.tsx
@@ -5,9 +5,15 @@ interface RowProps extends RowDefinition {
children: React.ReactNode;
hint?: string;
isEmpty?: boolean;
+ background?: React.ReactNode;
}
-export default function Row({ id, children, hint, isEmpty }: RowProps) {
+/**
+ * A single horizontal lane in the timeline. Wraps the dnd-timeline `useRow`
+ * hook and adds an optional `background` layer (e.g. `BackgroundWaveform`),
+ * an empty-state hint label, and a minimum height.
+ */
+export default function Row({ id, children, hint, isEmpty, background }: RowProps) {
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
return (
@@ -15,6 +21,7 @@ export default function Row({ id, children, hint, isEmpty }: RowProps) {
className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden"
style={{ ...rowWrapperStyle, minHeight: 36 }}
>
+ {background}
{isEmpty && hint && (
{hint}
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 759fcbbe..734e6e1e 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";
@@ -34,6 +35,7 @@ import type {
ZoomFocus,
ZoomRegion,
} from "../types";
+import BackgroundWaveform from "./BackgroundWaveform";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import Row from "./Row";
@@ -88,6 +90,8 @@ interface TimelineEditorProps {
onSelectSpeed?: (id: string | null) => void;
aspectRatio: AspectRatio;
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
+ videoUrl?: string;
+ showTrimWaveform?: boolean;
}
interface TimelineScaleConfig {
@@ -567,6 +571,8 @@ function Timeline({
selectedBlurId,
selectedSpeedId,
keyframes = [],
+ videoUrl,
+ showTrimWaveform = false,
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
@@ -584,12 +590,15 @@ function Timeline({
selectedBlurId?: string | null;
selectedSpeedId?: string | null;
keyframes?: { id: string; time: number }[];
+ videoUrl?: string;
+ showTrimWaveform?: boolean;
}) {
const t = useScopedT("timeline");
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef(null);
const isScrubbingTimelineRef = useRef(false);
const scrubPointerIdRef = useRef(null);
+ const peaks = useAudioPeaks(showTrimWaveform ? videoUrl : undefined);
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
@@ -788,7 +797,16 @@ function Timeline({
))}
-
+
+ ) : undefined
+ }
+ >
{trimItems.map((item) => (
- Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
@@ -1700,6 +1720,8 @@ export default function TimelineEditor({
selectedBlurId={selectedBlurId}
selectedSpeedId={selectedSpeedId}
keyframes={keyframes}
+ videoUrl={videoUrl}
+ showTrimWaveform={showTrimWaveform}
/>
diff --git a/src/hooks/audioPeaksWorker.ts b/src/hooks/audioPeaksWorker.ts
new file mode 100644
index 00000000..27812fd9
--- /dev/null
+++ b/src/hooks/audioPeaksWorker.ts
@@ -0,0 +1,40 @@
+/**
+ * Web Worker: computes min/max peak pairs from raw audio channel data.
+ *
+ * Input message: { channels: Float32Array[]; duration: number }
+ * Output message: Float32Array of length 2*N — [min0, max0, min1, max1, …]
+ *
+ * Channel buffers are transferred (zero-copy) from the caller.
+ * The peaks buffer is transferred back.
+ */
+self.onmessage = (event: MessageEvent<{ channels: Float32Array[]; duration: number }>) => {
+ const { channels, duration } = event.data;
+ const nCh = channels.length;
+ if (nCh === 0) {
+ (self as unknown as Worker).postMessage(new Float32Array(0));
+ return;
+ }
+
+ const totalSamples = channels[0].length;
+ const N = Math.min(24000, Math.ceil(duration * 200));
+ const blockSize = totalSamples / N;
+ const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …]
+
+ 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;
+ }
+
+ (self as unknown as Worker).postMessage(peaks, [peaks.buffer]);
+};
diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts
new file mode 100644
index 00000000..3be6ac61
--- /dev/null
+++ b/src/hooks/useAudioPeaks.ts
@@ -0,0 +1,120 @@
+import { useEffect, useRef, useState } from "react";
+import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder";
+
+let _audioCtx: AudioContext | null = null;
+/** Returns the shared AudioContext, creating it lazily on first call. */
+function getAudioCtx(): AudioContext {
+ if (!_audioCtx) _audioCtx = new AudioContext();
+ return _audioCtx;
+}
+
+/**
+ * Offloads peak computation to a Web Worker (zero-copy via Transferable).
+ * Accepts an optional AbortSignal — if aborted, the worker is terminated
+ * immediately and the promise rejects with an AbortError.
+ */
+function computePeaksInWorker(
+ audioBuffer: AudioBuffer,
+ signal?: AbortSignal,
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(new DOMException("Aborted", "AbortError"));
+ return;
+ }
+
+ const worker = new Worker(new URL("./audioPeaksWorker.ts", import.meta.url), {
+ type: "module",
+ });
+
+ const onAbort = () => {
+ worker.terminate();
+ reject(new DOMException("Aborted", "AbortError"));
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+
+ // slice() creates an owned copy so the transfer is safe and the
+ // AudioBuffer remains valid if anything else holds a reference.
+ const channels: Float32Array[] = [];
+ for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
+ channels.push(audioBuffer.getChannelData(c).slice());
+ }
+
+ worker.onmessage = (e: MessageEvent) => {
+ signal?.removeEventListener("abort", onAbort);
+ worker.terminate();
+ resolve(e.data);
+ };
+
+ worker.onerror = (e) => {
+ signal?.removeEventListener("abort", onAbort);
+ worker.terminate();
+ reject(e);
+ };
+
+ worker.postMessage(
+ { channels, duration: audioBuffer.duration },
+ channels.map((ch) => ch.buffer),
+ );
+ });
+}
+
+/**
+ * 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).
+ *
+ * - File loading uses the Electron IPC bridge for local paths (same as the exporter).
+ * - Peak computation runs in a Web Worker to avoid blocking the main thread.
+ * - Results are cached in a ref scoped to the hook instance (survives re-renders
+ * and waveform toggle off/on, but not component unmount).
+ */
+export function useAudioPeaks(videoUrl?: string): Float32Array | null {
+ const cacheRef = useRef