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 <noreply@anthropic.com>
This commit is contained in:
Davide Mendolia
2026-05-23 12:53:56 +02:00
co-authored by Claude Sonnet 4.5
parent 34340c2b29
commit 8413095aa9
8 changed files with 240 additions and 4 deletions
+31 -1
View File
@@ -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 && (
<Accordion type="multiple" value={[activePanelMode]} className="space-y-2">
<Accordion
type="multiple"
value={[activePanelMode, "timeline-settings"]}
className="space-y-2"
>
{hasWebcam && activePanelMode === "layout" && (
<AccordionItem value="layout" className="editor-panel-section px-3">
<AccordionTrigger className="py-2.5 hover:no-underline">
@@ -1696,6 +1705,27 @@ export function SettingsPanel({
</AccordionContent>
</AccordionItem>
)}
{/* Timeline Settings — always visible regardless of active panel mode */}
<AccordionItem value="timeline-settings" className="editor-panel-section px-3">
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<AudioWaveform className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">Timeline</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between p-2 rounded-lg editor-control-surface">
<div className="text-[10px] font-medium text-slate-300">Waveform</div>
<Switch
checked={showTrimWaveform}
onCheckedChange={onTrimWaveformChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
@@ -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}
/>
</div>
</Panel>
@@ -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: {
@@ -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<ProjectEditorState>): 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"
+4 -2
View File
@@ -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 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
<span className="text-[11px] text-white/[0.12] font-medium">{hint}</span>
<span className="text-[11px] text-white/[0.25] font-medium">{hint}</span>
</div>
)}
<div ref={setNodeRef} style={rowStyle}>
@@ -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<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).
* - 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<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;
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 (
<div ref={wrapperRef} className="absolute inset-0 pointer-events-none overflow-hidden">
<canvas
ref={canvasRef}
style={{ width: canvasSize.w, height: canvasSize.h, display: "block" }}
/>
</div>
);
}
@@ -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({
))}
</Row>
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
<Row
id={TRIM_ROW_ID}
isEmpty={trimItems.length === 0}
hint={t("hints.pressTrim")}
background={showTrimWaveform ? <RowWaveform videoUrl={videoUrl} videoDurationMs={videoDurationMs} /> : undefined}
>
{trimItems.map((item) => (
<Item
id={item.id}
@@ -899,6 +911,8 @@ export default function TimelineEditor({
onSelectSpeed,
aspectRatio,
onAspectRatioChange,
videoUrl,
showTrimWaveform = true,
}: TimelineEditorProps) {
const t = useScopedT("timeline");
const totalMs = useMemo(() => 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}
/>
</TimelineWrapper>
</div>
+2
View File
@@ -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,