mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-02 13:03:04 +08:00
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:
co-authored by
Claude Sonnet 4.5
parent
34340c2b29
commit
8413095aa9
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user