From 8413095aa93142bed5e8df3e2aa2239511aba5ad Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 12:53:56 +0200 Subject: [PATCH] feat: audio waveform on trim track + Timeline Settings toggle - Add RowWaveform component (refactored from TrimWaveform) that decodes audio via AudioContext, caches peaks per URL, and renders a faint waveform canvas behind any timeline row using useTimelineContext() for zoom/pan alignment - Wire RowWaveform as background of the trim row in TimelineEditor - Add showTrimWaveform boolean to EditorState / ProjectEditorState with default true; persists across saves/loads - Add Timeline Settings accordion section in SettingsPanel (always visible) with a Waveform toggle to enable/disable the visualization - Bump hint text opacity from 0.12 to 0.25 so "Press T to add trim" remains readable over the waveform background Co-Authored-By: Claude Sonnet 4.5 --- src/components/video-editor/SettingsPanel.tsx | 32 +++- src/components/video-editor/VideoEditor.tsx | 10 ++ src/components/video-editor/editorDefaults.ts | 2 + .../video-editor/projectPersistence.ts | 5 + src/components/video-editor/timeline/Row.tsx | 6 +- .../video-editor/timeline/RowWaveform.tsx | 169 ++++++++++++++++++ .../video-editor/timeline/TimelineEditor.tsx | 18 +- src/hooks/useEditorHistory.ts | 2 + 8 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 src/components/video-editor/timeline/RowWaveform.tsx diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d3683976..a32fd804 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,5 +1,6 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { + AudioWaveform, Bug, Crop, Download, @@ -258,6 +259,8 @@ interface SettingsPanelProps { onShadowCommit?: () => void; showBlur?: boolean; onBlurChange?: (showBlur: boolean) => void; + showTrimWaveform?: boolean; + onTrimWaveformChange?: (show: boolean) => void; motionBlurAmount?: number; onMotionBlurChange?: (amount: number) => void; onMotionBlurCommit?: () => void; @@ -389,6 +392,8 @@ export function SettingsPanel({ onShadowCommit, showBlur, onBlurChange, + showTrimWaveform = true, + onTrimWaveformChange, motionBlurAmount = 0, onMotionBlurChange, onMotionBlurCommit, @@ -1178,7 +1183,11 @@ export function SettingsPanel({ )} {!hasTimelineSelection && ( - + {hasWebcam && activePanelMode === "layout" && ( @@ -1696,6 +1705,27 @@ export function SettingsPanel({ )} + {/* Timeline Settings — always visible regardless of active panel mode */} + + +
+ + Timeline +
+
+ +
+
+
Waveform
+ +
+
+
+
)} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b32621c9..e645ec7c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -169,6 +169,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -355,6 +356,7 @@ export default function VideoEditor() { wallpaper: normalizedEditor.wallpaper, shadowIntensity: normalizedEditor.shadowIntensity, showBlur: normalizedEditor.showBlur, + showTrimWaveform: normalizedEditor.showTrimWaveform, motionBlurAmount: normalizedEditor.motionBlurAmount, borderRadius: normalizedEditor.borderRadius, padding: normalizedEditor.padding, @@ -426,6 +428,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -449,6 +452,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -572,6 +576,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -631,6 +636,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -2192,6 +2198,8 @@ export default function VideoEditor() { onShadowCommit={commitState} showBlur={showBlur} onBlurChange={(v) => pushState({ showBlur: v })} + showTrimWaveform={showTrimWaveform} + onTrimWaveformChange={(v) => pushState({ showTrimWaveform: v })} motionBlurAmount={motionBlurAmount} onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} onMotionBlurCommit={commitState} @@ -2355,6 +2363,8 @@ export default function VideoEditor() { : webcamLayoutPreset, }) } + videoUrl={videoPath ?? undefined} + showTrimWaveform={showTrimWaveform} /> diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index ac6ab614..2e1e9510 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -34,11 +34,13 @@ export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { showBlur: boolean; motionBlurAmount: number; borderRadius: number; + showTrimWaveform: boolean; } = { shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, borderRadius: 0, + showTrimWaveform: true, }; export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index f16d29ea..46673290 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -65,6 +65,7 @@ export interface ProjectEditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; + showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -445,6 +446,10 @@ export function normalizeProjectEditor(editor: Partial): Pro typeof editor.showBlur === "boolean" ? editor.showBlur : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + showTrimWaveform: + typeof editor.showTrimWaveform === "boolean" + ? editor.showTrimWaveform + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) ? clamp(editor.motionBlurAmount, 0, 1) : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 77fb52de..d104e92d 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -5,9 +5,10 @@ interface RowProps extends RowDefinition { children: React.ReactNode; hint?: string; isEmpty?: boolean; + background?: React.ReactNode; } -export default function Row({ id, children, hint, isEmpty }: RowProps) { +export default function Row({ id, children, hint, isEmpty, background }: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); return ( @@ -15,9 +16,10 @@ 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} + {hint}
)}
diff --git a/src/components/video-editor/timeline/RowWaveform.tsx b/src/components/video-editor/timeline/RowWaveform.tsx new file mode 100644 index 00000000..0ebce152 --- /dev/null +++ b/src/components/video-editor/timeline/RowWaveform.tsx @@ -0,0 +1,169 @@ +import { useTimelineContext } from "dnd-timeline"; +import { useEffect, useRef, useState } from "react"; + +export interface RowWaveformProps { + videoUrl?: string; + 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). + * - 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) { + 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; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setCanvasSize({ w: width, h: height }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Redraw whenever peaks, range, or container size changes. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !peaks || 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); + + 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/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 759fcbbe..76959a3e 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -38,6 +38,7 @@ import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; import TimelineWrapper from "./TimelineWrapper"; +import RowWaveform from "./RowWaveform"; import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; const ZOOM_ROW_ID = "row-zoom"; @@ -88,6 +89,8 @@ interface TimelineEditorProps { onSelectSpeed?: (id: string | null) => void; aspectRatio: AspectRatio; onAspectRatioChange: (aspectRatio: AspectRatio) => void; + videoUrl?: string; + showTrimWaveform?: boolean; } interface TimelineScaleConfig { @@ -567,6 +570,8 @@ function Timeline({ selectedBlurId, selectedSpeedId, keyframes = [], + videoUrl, + showTrimWaveform = true, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -584,6 +589,8 @@ 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(); @@ -788,7 +795,12 @@ function Timeline({ ))}
- + : undefined} + > {trimItems.map((item) => ( Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); @@ -1700,6 +1714,8 @@ export default function TimelineEditor({ selectedBlurId={selectedBlurId} selectedSpeedId={selectedSpeedId} keyframes={keyframes} + videoUrl={videoUrl} + showTrimWaveform={showTrimWaveform} />
diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index b6525c10..bfa1a4be 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -29,6 +29,7 @@ export interface EditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; + showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -48,6 +49,7 @@ export const INITIAL_EDITOR_STATE: EditorState = { wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + showTrimWaveform: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,