From 8413095aa93142bed5e8df3e2aa2239511aba5ad Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 12:53:56 +0200 Subject: [PATCH 01/19] 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, From 70c7d20d3c2b8068c69d408d5eedacb6a07d2365 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 15:45:00 +0200 Subject: [PATCH 02/19] fix: timeline waveform in dedicated Timeline panel, disabled by default - Add "Timeline" as a dedicated SettingsPanelMode with AudioWaveform icon - Waveform toggle now lives exclusively in the Timeline panel (not in every panel) - Default showTrimWaveform to false so trim row starts with visible hint text - Guard AccordionItem render with activePanelMode === "timeline" to prevent bleed Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/SettingsPanel.tsx | 58 +++++++++---------- src/components/video-editor/editorDefaults.ts | 2 +- .../video-editor/timeline/TimelineEditor.tsx | 12 ++-- 3 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index a32fd804..2a33d6bd 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -346,7 +346,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; -type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export"; +type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export" | "timeline"; const MP4_EXPORT_SHORT_SIDES = { medium: 720, @@ -392,7 +392,7 @@ export function SettingsPanel({ onShadowCommit, showBlur, onBlurChange, - showTrimWaveform = true, + showTrimWaveform = false, onTrimWaveformChange, motionBlurAmount = 0, onMotionBlurChange, @@ -608,6 +608,7 @@ export function SettingsPanel({ { id: "background", label: t("background.title"), icon: Palette }, { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, + { id: "timeline", label: "Timeline", icon: AudioWaveform }, ...(hasCursorPanel ? [ { @@ -629,8 +630,10 @@ export function SettingsPanel({ : selectedSpeedId ? t("speed.playbackSpeed") : t("trim.deleteRegion") - : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? - t("background.title")); + : activePanelMode === "timeline" + ? "Timeline" + : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? + t("background.title")); const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { @@ -1183,11 +1186,7 @@ export function SettingsPanel({ )} {!hasTimelineSelection && ( - + {hasWebcam && activePanelMode === "layout" && ( @@ -1705,27 +1704,28 @@ export function SettingsPanel({ )} - {/* Timeline Settings — always visible regardless of active panel mode */} - - -
- - Timeline -
-
- -
-
-
Waveform
- + {activePanelMode === "timeline" && ( + + +
+ + Timeline
-
- - + + +
+
+
Waveform
+ +
+
+
+ + )} )}
diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index 2e1e9510..914ec769 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -40,7 +40,7 @@ export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { showBlur: false, motionBlurAmount: 0, borderRadius: 0, - showTrimWaveform: true, + showTrimWaveform: false, }; export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 76959a3e..d0026610 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -37,8 +37,8 @@ import type { import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; -import TimelineWrapper from "./TimelineWrapper"; import RowWaveform from "./RowWaveform"; +import TimelineWrapper from "./TimelineWrapper"; import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; const ZOOM_ROW_ID = "row-zoom"; @@ -571,7 +571,7 @@ function Timeline({ selectedSpeedId, keyframes = [], videoUrl, - showTrimWaveform = true, + showTrimWaveform = false, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -799,7 +799,11 @@ function Timeline({ id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")} - background={showTrimWaveform ? : undefined} + background={ + showTrimWaveform ? ( + + ) : undefined + } > {trimItems.map((item) => ( Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); From 5b4f31f661366600a1e8afed3a31bfddec73269e Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 16:57:19 +0200 Subject: [PATCH 03/19] chore: swap AudioWaveform icon for Brackets, improve toggle label - Replace AudioWaveform icon with Brackets on the Timeline panel tab and section header - Rename toggle label from "Waveform" to "Show Audio Waveform on Trim Track" - Make toggle full-width to accommodate the longer label Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/SettingsPanel.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 2a33d6bd..2f54c75f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,6 +1,6 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { - AudioWaveform, + Brackets, Bug, Crop, Download, @@ -608,7 +608,7 @@ export function SettingsPanel({ { id: "background", label: t("background.title"), icon: Palette }, { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, - { id: "timeline", label: "Timeline", icon: AudioWaveform }, + { id: "timeline", label: "Timeline", icon: Brackets }, ...(hasCursorPanel ? [ { @@ -1708,20 +1708,20 @@ export function SettingsPanel({
- + Timeline
-
-
-
Waveform
- +
+
+ Show Audio Waveform on Trim Track
+
From 46732fc76b0154c6f1c90728021f98e75c0ee561 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:08:38 +0200 Subject: [PATCH 04/19] revert: restore hint text opacity to 0.12 in Row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.25 bump was a workaround for hint visibility over the waveform background, but is now stale — waveform defaults to off and hints only appear on empty rows anyway. Restores consistency with all other row hints. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/timeline/Row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index d104e92d..f3d11bcf 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -19,7 +19,7 @@ export default function Row({ id, children, hint, isEmpty, background }: RowProp {background} {isEmpty && hint && (
- {hint} + {hint}
)}
From a72aa1a594af5aeaa90c573e71195e930671ccb6 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:14:20 +0200 Subject: [PATCH 05/19] 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 --- .../video-editor/timeline/RowWaveform.tsx | 83 +---------------- .../video-editor/timeline/TimelineEditor.tsx | 4 +- src/hooks/useAudioPeaks.ts | 90 +++++++++++++++++++ 3 files changed, 97 insertions(+), 80 deletions(-) create mode 100644 src/hooks/useAudioPeaks.ts diff --git a/src/components/video-editor/timeline/RowWaveform.tsx b/src/components/video-editor/timeline/RowWaveform.tsx index 0ebce152..a77ee2c8 100644 --- a/src/components/video-editor/timeline/RowWaveform.tsx +++ b/src/components/video-editor/timeline/RowWaveform.tsx @@ -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(); -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). + * - 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(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; diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index d0026610..3bb420d8 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"; @@ -597,6 +598,7 @@ function Timeline({ const localTimelineRef = useRef(null); const isScrubbingTimelineRef = useRef(false); const scrubPointerIdRef = useRef(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 ? ( - + ) : undefined } > diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts new file mode 100644 index 00000000..206ad1d1 --- /dev/null +++ b/src/hooks/useAudioPeaks.ts @@ -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(); + +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(() => + 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; +} From 514943c1429c93373804aba04c4762deb96348fc Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:26:54 +0200 Subject: [PATCH 06/19] refactor: extract loadFileAsArrayBuffer to eliminate duplicated file loading Add a single exported loadFileAsArrayBuffer in streamingDecoder.ts that owns the IPC-vs-fetch branching for local and remote video URLs. StreamingVideoDecoder.loadSourceFile and useAudioPeaks both delegate to it, removing the duplicated readBinaryFile/fetch logic. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAudioPeaks.ts | 5 +-- src/lib/exporter/streamingDecoder.ts | 60 +++++++++++++--------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 206ad1d1..00e60675 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; // Module-level cache keyed by URL — survives re-mounts within the same page session. const peaksCache = new Map(); @@ -67,9 +68,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { (async () => { try { - const response = await fetch(videoUrl); - if (cancelled) return; - const arrayBuffer = await response.arrayBuffer(); + const arrayBuffer = await loadFileAsArrayBuffer(videoUrl); if (cancelled) return; const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); if (cancelled) return; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index e8093df0..f7687233 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -137,6 +137,29 @@ export function shouldFailDecodeEndedEarly({ return true; } +/** + * Loads a video file as an ArrayBuffer, using the Electron IPC bridge for + * local paths and falling back to `fetch` for remote / blob / data URLs. + * This is the single canonical place for reading raw video bytes in the renderer. + */ +export async function loadFileAsArrayBuffer(videoUrl: string): Promise { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + + if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { + const result = await window.electronAPI.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message ?? result.error ?? "Failed to read video file"); + } + return result.data; + } + + const response = await fetch(videoUrl); + if (!response.ok) { + throw new Error(`Failed to fetch video file: ${response.status} ${response.statusText}`); + } + return response.arrayBuffer(); +} + /** Caller must close the VideoFrame after use. */ type OnFrameCallback = ( frame: VideoFrame, @@ -158,43 +181,16 @@ export class StreamingVideoDecoder { private metadata: DecodedVideoInfo | null = null; private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { - const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); - - if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { - const result = await this.withTimeout( - window.electronAPI.readBinaryFile(videoUrl), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while loading the source video.", - ); - if (!result.success || !result.data) { - throw new Error(result.message || result.error || "Failed to read source video"); - } - - const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; - const blob = new Blob([result.data]); - return { - blob, - file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), - }; - } - - const response = await this.withTimeout( - fetch(videoUrl), + const buffer = await this.withTimeout( + loadFileAsArrayBuffer(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); - if (!response.ok) { - throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`); - } - const blob = await this.withTimeout( - response.blob(), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while reading the source video.", - ); - const filename = videoUrl.split("/").pop() || "video"; + const filename = videoUrl.split(/[\\/]/).pop() || "video"; + const blob = new Blob([buffer]); return { blob, - file: new File([blob], filename, { type: blob.type }), + file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), }; } From f67e997688cf77057b94a860463707342100431e Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:34:38 +0200 Subject: [PATCH 07/19] refactor: offload peak computation to Web Worker, replace module cache with useRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move computePeaks into audioPeaksWorker.ts — runs off the main thread with zero-copy channel buffer transfer - Replace module-level peaksCache Map with a useRef scoped to the hook instance — no global mutable state, toggle off/on still instant Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/audioPeaksWorker.ts | 40 +++++++++++++++++++ src/hooks/useAudioPeaks.ts | 73 +++++++++++++++++++---------------- 2 files changed, 80 insertions(+), 33 deletions(-) create mode 100644 src/hooks/audioPeaksWorker.ts 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 index 00e60675..18de90a5 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -1,42 +1,44 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; -// 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)); +/** + * Offloads peak computation to a Web Worker (zero-copy via Transferable). + * Returns a Promise that resolves with the peaks Float32Array. + */ +function computePeaksInWorker(audioBuffer: AudioBuffer): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL("./audioPeaksWorker.ts", import.meta.url), { + type: "module", + }); - 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; + // 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()); } - peaks[i * 2] = minVal; - peaks[i * 2 + 1] = maxVal; - } - return peaks; + worker.onmessage = (e: MessageEvent) => { + worker.terminate(); + resolve(e.data); + }; + + worker.onerror = (e) => { + worker.terminate(); + reject(e); + }; + + worker.postMessage( + { channels, duration: audioBuffer.duration }, + channels.map((ch) => ch.buffer), + ); + }); } /** @@ -45,11 +47,15 @@ function computePeaks(audioBuffer: AudioBuffer): Float32Array { * 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. + * - 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>(new Map()); const [peaks, setPeaks] = useState(() => - videoUrl ? (peaksCache.get(videoUrl) ?? null) : null, + videoUrl ? (cacheRef.current.get(videoUrl) ?? null) : null, ); useEffect(() => { @@ -58,7 +64,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { return; } - const cached = peaksCache.get(videoUrl); + const cached = cacheRef.current.get(videoUrl); if (cached) { setPeaks(cached); return; @@ -72,8 +78,9 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { if (cancelled) return; const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); if (cancelled) return; - const p = computePeaks(audioBuffer); - peaksCache.set(videoUrl, p); + const p = await computePeaksInWorker(audioBuffer); + if (cancelled) return; + cacheRef.current.set(videoUrl, p); setPeaks(p); } catch { // No audio track or unsupported format — silent degradation. From b7d7c08906ab80932748ee012c6f73f2a47a251f Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:43:13 +0200 Subject: [PATCH 08/19] i18n: add Timeline panel translations for all 12 locales Add timeline.title and timeline.waveform keys to every settings.json locale file. Replace all hardcoded "Timeline" and "Show Audio Waveform on Trim Track" strings in SettingsPanel with t() calls. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/SettingsPanel.tsx | 8 ++++---- src/i18n/locales/ar/settings.json | 4 ++++ src/i18n/locales/en/settings.json | 4 ++++ src/i18n/locales/es/settings.json | 4 ++++ src/i18n/locales/fr/settings.json | 4 ++++ src/i18n/locales/it/settings.json | 4 ++++ src/i18n/locales/ja-JP/settings.json | 4 ++++ src/i18n/locales/ko-KR/settings.json | 4 ++++ src/i18n/locales/ru/settings.json | 4 ++++ src/i18n/locales/tr/settings.json | 4 ++++ src/i18n/locales/vi/settings.json | 4 ++++ src/i18n/locales/zh-CN/settings.json | 4 ++++ src/i18n/locales/zh-TW/settings.json | 4 ++++ 13 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 2f54c75f..6534180f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -608,7 +608,7 @@ export function SettingsPanel({ { id: "background", label: t("background.title"), icon: Palette }, { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, - { id: "timeline", label: "Timeline", icon: Brackets }, + { id: "timeline", label: t("timeline.title"), icon: Brackets }, ...(hasCursorPanel ? [ { @@ -631,7 +631,7 @@ export function SettingsPanel({ ? t("speed.playbackSpeed") : t("trim.deleteRegion") : activePanelMode === "timeline" - ? "Timeline" + ? t("timeline.title") : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? t("background.title")); @@ -1709,13 +1709,13 @@ export function SettingsPanel({
- Timeline + {t("timeline.title")}
- Show Audio Waveform on Trim Track + {t("timeline.waveform")}
Date: Sat, 23 May 2026 18:06:28 +0200 Subject: [PATCH 09/19] refactor: rename RowWaveform to BackgroundWaveform, drop wrapper div MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename to BackgroundWaveform to reflect its visual role rather than structural placement. Remove the redundant wrapper div — Row already provides relative overflow-hidden, so the canvas can sit absolute inset-0 directly and observe itself via ResizeObserver. Co-Authored-By: Claude Sonnet 4.6 --- ...RowWaveform.tsx => BackgroundWaveform.tsx} | 32 ++++++++----------- .../video-editor/timeline/TimelineEditor.tsx | 4 +-- 2 files changed, 15 insertions(+), 21 deletions(-) rename src/components/video-editor/timeline/{RowWaveform.tsx => BackgroundWaveform.tsx} (72%) diff --git a/src/components/video-editor/timeline/RowWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx similarity index 72% rename from src/components/video-editor/timeline/RowWaveform.tsx rename to src/components/video-editor/timeline/BackgroundWaveform.tsx index a77ee2c8..45426ebf 100644 --- a/src/components/video-editor/timeline/RowWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -1,39 +1,40 @@ import { useTimelineContext } from "dnd-timeline"; import { useEffect, useRef, useState } from "react"; -export interface RowWaveformProps { +export interface BackgroundWaveformProps { /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */ peaks: Float32Array | null; videoDurationMs: number; } /** - * Renders a faint audio waveform on a `` element that fills its - * containing row. Designed to be passed as the `background` prop of ``. + * Renders a faint audio waveform on a `` that fills its containing + * block. Designed to be passed as the `background` prop of ``, which + * already provides `relative overflow-hidden` — no wrapper element needed. * * - 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. + * - `pointer-events: none` — never blocks drag-to-create interactions. */ -export default function RowWaveform({ peaks, videoDurationMs }: RowWaveformProps) { +export default function BackgroundWaveform({ peaks, videoDurationMs }: BackgroundWaveformProps) { const { range } = useTimelineContext(); const canvasRef = useRef(null); - const wrapperRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); - // Track container dimensions via ResizeObserver. + // Observe the canvas itself — Row's `relative overflow-hidden` parent + // makes it fill the row exactly, so no wrapper div is needed. useEffect(() => { - const el = wrapperRef.current; - if (!el) return; + 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(el); + ro.observe(canvas); return () => ro.disconnect(); }, []); - // Redraw whenever peaks, range, or container size changes. + // Redraw whenever peaks, range, or canvas size changes. useEffect(() => { const canvas = canvasRef.current; if (!canvas || !peaks || canvasSize.w <= 0 || canvasSize.h <= 0) return; @@ -83,12 +84,5 @@ export default function RowWaveform({ peaks, videoDurationMs }: RowWaveformProps ctx.stroke(); }, [peaks, range, canvasSize, videoDurationMs]); - return ( -
- -
- ); + return ; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 3bb420d8..734e6e1e 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -35,10 +35,10 @@ import type { ZoomFocus, ZoomRegion, } from "../types"; +import BackgroundWaveform from "./BackgroundWaveform"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; -import RowWaveform from "./RowWaveform"; import TimelineWrapper from "./TimelineWrapper"; import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; @@ -803,7 +803,7 @@ function Timeline({ hint={t("hints.pressTrim")} background={ showTrimWaveform ? ( - + ) : undefined } > From f19d1bc668873bc094b166b939d35b52f54de785 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 18:10:36 +0200 Subject: [PATCH 10/19] fix: clear stale peaks before decoding a new source Reset peaks to null immediately when an uncached URL is set, so the previous video's waveform is not shown during decode. Also clear on decode failure so stale data does not persist indefinitely. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAudioPeaks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 18de90a5..38875370 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -70,6 +70,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { return; } + setPeaks(null); let cancelled = false; (async () => { @@ -83,7 +84,8 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { cacheRef.current.set(videoUrl, p); setPeaks(p); } catch { - // No audio track or unsupported format — silent degradation. + // No audio track or unsupported format — clear stale data silently. + if (!cancelled) setPeaks(null); } })(); From 87f7268de7b3723920e429e46b5e5008b3b10bfd Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 18:11:44 +0200 Subject: [PATCH 11/19] i18n(zh-TW): align waveform label with existing trim terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use 剪輯 (editing/clip) instead of 修剪 (trimming) to match the term already used in the trim section throughout zh-TW strings. Co-Authored-By: Claude Sonnet 4.6 --- src/i18n/locales/zh-TW/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 202a4ed9..e579aeae 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -207,6 +207,6 @@ }, "timeline": { "title": "時間軸", - "waveform": "在修剪軌道上顯示音訊波形" + "waveform": "在剪輯軌道上顯示音訊波形" } } From 9f55611f72ca3ae1db6321115b5c04fcf60b273a Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 18:12:54 +0200 Subject: [PATCH 12/19] fix: always clear canvas before bailing on empty peaks Move the peaks null-check after clearRect so a previously drawn waveform is erased when peaks transitions to null (e.g. on URL change or decode failure) rather than lingering on screen. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/timeline/BackgroundWaveform.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index 45426ebf..05d8e650 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -37,7 +37,7 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun // Redraw whenever peaks, range, or canvas size changes. useEffect(() => { const canvas = canvasRef.current; - if (!canvas || !peaks || canvasSize.w <= 0 || canvasSize.h <= 0) return; + if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return; const dpr = window.devicePixelRatio || 1; canvas.width = Math.round(canvasSize.w * dpr); @@ -49,6 +49,8 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun 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; From 9a72f31148aa8b0461fb92a42fac9e1deb1eaea2 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 18:33:27 +0200 Subject: [PATCH 13/19] docs: add missing JSDoc to satisfy docstring coverage threshold Add docstrings to all undocumented functions and methods across the files touched in this branch: getAudioCtx, Row component, and all StreamingVideoDecoder methods (loadSourceFile, computeSegments, getExportMetrics, splitBySpeed, getDemuxer, cancel, destroy, withTimeout). Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/timeline/Row.tsx | 5 +++++ src/hooks/useAudioPeaks.ts | 1 + src/lib/exporter/streamingDecoder.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index f3d11bcf..17a59e83 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -8,6 +8,11 @@ interface RowProps extends RowDefinition { background?: React.ReactNode; } +/** + * 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 }); diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 38875370..d640e961 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -2,6 +2,7 @@ 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; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index f7687233..fbe1453f 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -180,6 +180,7 @@ export class StreamingVideoDecoder { private cancelled = false; private metadata: DecodedVideoInfo | null = null; + /** Loads the video file and returns it as both a Blob and a File for WebDemuxer. */ private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const buffer = await this.withTimeout( loadFileAsArrayBuffer(videoUrl), @@ -616,6 +617,10 @@ export class StreamingVideoDecoder { } } + /** + * Converts trim regions into the segments that should be kept. + * Returns a single full-duration segment when no trim regions are present. + */ private computeSegments( totalDuration: number, trimRegions?: TrimRegion[], @@ -644,6 +649,11 @@ export class StreamingVideoDecoder { return segments; } + /** + * Calculates the effective output duration (in seconds) and total frame count + * for a given combination of trim and speed regions at the target frame rate. + * Requires `loadMetadata()` to have been called first. + */ getExportMetrics( targetFrameRate: number, trimRegions?: TrimRegion[], @@ -664,6 +674,10 @@ export class StreamingVideoDecoder { }; } + /** + * Splits keep-segments by overlapping speed regions, annotating each + * sub-segment with its playback speed multiplier (defaults to 1×). + */ private splitBySpeed( segments: Array<{ startSec: number; endSec: number }>, speedRegions?: SpeedRegion[], @@ -696,14 +710,17 @@ export class StreamingVideoDecoder { return result.filter((s) => s.endSec - s.startSec > 0.0001); } + /** Returns the underlying WebDemuxer instance, or null if not yet loaded. */ getDemuxer(): WebDemuxer | null { return this.demuxer; } + /** Signals the decoder to stop processing at the next cancellation checkpoint. */ cancel(): void { this.cancelled = true; } + /** Cancels decoding and releases the VideoDecoder and WebDemuxer resources. */ destroy(): void { this.cancelled = true; @@ -726,6 +743,7 @@ export class StreamingVideoDecoder { } } + /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); From 928cabc68df85909b3bd1164f0b031acb8c93c8a Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 19:04:35 +0200 Subject: [PATCH 14/19] fix: add AbortSignal to worker, fix data: URL filename in loadSourceFile - computePeaksInWorker now accepts an AbortSignal; terminates the worker immediately on abort and rejects with AbortError so orphaned workers cannot outlive the effect cleanup - useAudioPeaks wires an AbortController into the effect; controller.abort() fires alongside the existing cancelled flag on URL change or unmount - loadSourceFile handles data: URLs by parsing the MIME type, deriving a safe filename (e.g. video.mp4), and setting Blob type correctly Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAudioPeaks.ts | 29 ++++++++++++++++++++++++---- src/lib/exporter/streamingDecoder.ts | 17 ++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index d640e961..1538bc94 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -10,14 +10,29 @@ function getAudioCtx(): AudioContext { /** * Offloads peak computation to a Web Worker (zero-copy via Transferable). - * Returns a Promise that resolves with the peaks Float32Array. + * Accepts an optional AbortSignal — if aborted, the worker is terminated + * immediately and the promise rejects with an AbortError. */ -function computePeaksInWorker(audioBuffer: AudioBuffer): Promise { +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[] = []; @@ -26,11 +41,13 @@ function computePeaksInWorker(audioBuffer: AudioBuffer): Promise { } worker.onmessage = (e: MessageEvent) => { + signal?.removeEventListener("abort", onAbort); worker.terminate(); resolve(e.data); }; worker.onerror = (e) => { + signal?.removeEventListener("abort", onAbort); worker.terminate(); reject(e); }; @@ -73,6 +90,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { setPeaks(null); let cancelled = false; + const controller = new AbortController(); (async () => { try { @@ -80,11 +98,13 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { if (cancelled) return; const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); if (cancelled) return; - const p = await computePeaksInWorker(audioBuffer); + const p = await computePeaksInWorker(audioBuffer, controller.signal); if (cancelled) return; cacheRef.current.set(videoUrl, p); setPeaks(p); - } catch { + } catch (err) { + // AbortError means the effect cleaned up — no state update needed. + if (err instanceof DOMException && err.name === "AbortError") return; // No audio track or unsupported format — clear stale data silently. if (!cancelled) setPeaks(null); } @@ -92,6 +112,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { return () => { cancelled = true; + controller.abort(); }; }, [videoUrl]); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index fbe1453f..71adfa12 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -187,8 +187,21 @@ export class StreamingVideoDecoder { SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); - const filename = videoUrl.split(/[\\/]/).pop() || "video"; - const blob = new Blob([buffer]); + + let filename: string; + let mime = ""; + + if (videoUrl.startsWith("data:")) { + // data:video/mp4;base64,... → mime="video/mp4", filename="video.mp4" + const mimeMatch = videoUrl.match(/^data:([^;,]+)/); + mime = mimeMatch?.[1] ?? ""; + const ext = mime.split("/")[1]?.replace(/[^a-z0-9]/gi, "") ?? ""; + filename = ext ? `video.${ext}` : "video"; + } else { + filename = videoUrl.split(/[\\/]/).pop() || "video"; + } + + const blob = new Blob([buffer], { type: mime }); return { blob, file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), From d06bf3747134248d2fb1fdcb725339909329f984 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 11:30:06 +0200 Subject: [PATCH 15/19] fix: propagate Content-Type from fetch through loadFileAsArrayBuffer Change return type to { data, contentType } so the fetched Content-Type header is available to callers. loadSourceFile uses it to set the Blob MIME type for https:/blob: URLs instead of always falling back to application/octet-stream. useAudioPeaks destructures only { data }. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAudioPeaks.ts | 2 +- src/lib/exporter/streamingDecoder.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 1538bc94..3be6ac61 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -94,7 +94,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { (async () => { try { - const arrayBuffer = await loadFileAsArrayBuffer(videoUrl); + const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl); if (cancelled) return; const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); if (cancelled) return; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 71adfa12..6f28127c 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -140,9 +140,13 @@ export function shouldFailDecodeEndedEarly({ /** * Loads a video file as an ArrayBuffer, using the Electron IPC bridge for * local paths and falling back to `fetch` for remote / blob / data URLs. + * Also returns the `contentType` from the response headers (empty string for + * local IPC reads where no Content-Type is available). * This is the single canonical place for reading raw video bytes in the renderer. */ -export async function loadFileAsArrayBuffer(videoUrl: string): Promise { +export async function loadFileAsArrayBuffer( + videoUrl: string, +): Promise<{ data: ArrayBuffer; contentType: string }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { @@ -150,14 +154,15 @@ export async function loadFileAsArrayBuffer(videoUrl: string): Promise { - const buffer = await this.withTimeout( + const { data: buffer, contentType } = await this.withTimeout( loadFileAsArrayBuffer(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", @@ -198,6 +203,7 @@ export class StreamingVideoDecoder { const ext = mime.split("/")[1]?.replace(/[^a-z0-9]/gi, "") ?? ""; filename = ext ? `video.${ext}` : "video"; } else { + mime = contentType; filename = videoUrl.split(/[\\/]/).pop() || "video"; } From 69d0aa4d30c5cb82eed2f0b0f473313916331570 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 11:43:22 +0200 Subject: [PATCH 16/19] refactor: split loadSourceFile into loadLocalSourceFile and loadRemoteSourceFile Restore loadSourceFile as a thin router (local vs remote) and extract the IPC and fetch paths into dedicated private methods, each containing the original logic from main. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/exporter/streamingDecoder.ts | 241 ++++++++++++++++++++------- 1 file changed, 182 insertions(+), 59 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 6f28127c..ac3dffb4 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -19,7 +19,11 @@ function buildAV1CodecString(description?: BufferSource): string { const bytes = description instanceof ArrayBuffer ? new Uint8Array(description) - : new Uint8Array(description.buffer, description.byteOffset, description.byteLength); + : new Uint8Array( + description.buffer, + description.byteOffset, + description.byteLength, + ); // AV1CodecConfigurationRecord layout (4+ bytes): // Byte 0: marker (1) | version (7) @@ -87,16 +91,24 @@ const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60; * @param containerDuration Duration from the container-level metadata * @param scannedDuration Duration derived from actual packet timestamps (ground truth) */ -export function validateDuration(containerDuration: number, scannedDuration: number): number { +export function validateDuration( + containerDuration: number, + scannedDuration: number, +): number { if (scannedDuration <= 0) { // Zero scanned duration means corrupted/empty file — fall back to container // (downstream shouldFailDecodeEndedEarly will catch truly empty files) - return Number.isFinite(containerDuration) ? Math.max(containerDuration, 0) : 0; + return Number.isFinite(containerDuration) + ? Math.max(containerDuration, 0) + : 0; } if (!Number.isFinite(containerDuration) || containerDuration <= 0) { return scannedDuration; } - if (Math.abs(containerDuration - scannedDuration) > DURATION_DIVERGENCE_THRESHOLD_SEC) { + if ( + Math.abs(containerDuration - scannedDuration) > + DURATION_DIVERGENCE_THRESHOLD_SEC + ) { return scannedDuration; } return containerDuration; @@ -121,16 +133,27 @@ export function shouldFailDecodeEndedEarly({ return false; } - if (typeof streamDurationSec !== "number" || !Number.isFinite(streamDurationSec)) { + if ( + typeof streamDurationSec !== "number" || + !Number.isFinite(streamDurationSec) + ) { return true; } const metadataTailSec = requiredEndSec - streamDurationSec; const decodedNearStreamEnd = - Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC; - - const maxTailSec = Math.max(METADATA_TAIL_TOLERANCE_SEC, requiredEndSec * 0.01); - if (decodedNearStreamEnd && metadataTailSec > 0 && metadataTailSec <= maxTailSec) { + Math.abs(lastDecodedFrameSec - streamDurationSec) <= + STREAM_DURATION_MATCH_TOLERANCE_SEC; + + const maxTailSec = Math.max( + METADATA_TAIL_TOLERANCE_SEC, + requiredEndSec * 0.01, + ); + if ( + decodedNearStreamEnd && + metadataTailSec > 0 && + metadataTailSec <= maxTailSec + ) { return false; } @@ -152,16 +175,21 @@ export async function loadFileAsArrayBuffer( if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { - throw new Error(result.message ?? result.error ?? "Failed to read video file"); + throw new Error( + result.message ?? result.error ?? "Failed to read video file", + ); } return { data: result.data, contentType: "" }; } const response = await fetch(videoUrl); if (!response.ok) { - throw new Error(`Failed to fetch video file: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to fetch video file: ${response.status} ${response.statusText}`, + ); } - const contentType = response.headers.get("content-type")?.split(";")[0].trim() ?? ""; + const contentType = + response.headers.get("content-type")?.split(";")[0].trim() ?? ""; return { data: await response.arrayBuffer(), contentType }; } @@ -185,32 +213,65 @@ export class StreamingVideoDecoder { private cancelled = false; private metadata: DecodedVideoInfo | null = null; - /** Loads the video file and returns it as both a Blob and a File for WebDemuxer. */ - private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { - const { data: buffer, contentType } = await this.withTimeout( - loadFileAsArrayBuffer(videoUrl), + /** Routes to the appropriate loader based on whether the source is local or remote. */ + private async loadSourceFile( + videoUrl: string, + ): Promise<{ file: File; blob: Blob }> { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + if (!isRemoteUrl && window.electronAPI) { + return this.loadLocalSourceFile(videoUrl); + } + return this.loadRemoteSourceFile(videoUrl); + } + + /** Loads a local video file via the Electron IPC bridge. */ + private async loadLocalSourceFile( + videoUrl: string, + ): Promise<{ file: File; blob: Blob }> { + const result = await this.withTimeout( + window.electronAPI.readBinaryFile(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); - - let filename: string; - let mime = ""; - - if (videoUrl.startsWith("data:")) { - // data:video/mp4;base64,... → mime="video/mp4", filename="video.mp4" - const mimeMatch = videoUrl.match(/^data:([^;,]+)/); - mime = mimeMatch?.[1] ?? ""; - const ext = mime.split("/")[1]?.replace(/[^a-z0-9]/gi, "") ?? ""; - filename = ext ? `video.${ext}` : "video"; - } else { - mime = contentType; - filename = videoUrl.split(/[\\/]/).pop() || "video"; + if (!result.success || !result.data) { + throw new Error( + result.message || result.error || "Failed to read source video", + ); } - const blob = new Blob([buffer], { type: mime }); + const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; + const blob = new Blob([result.data]); return { blob, - file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), + file: new File([blob], filename, { + type: blob.type || "application/octet-stream", + }), + }; + } + + /** Loads a remote or blob video URL via fetch. */ + private async loadRemoteSourceFile( + videoUrl: string, + ): Promise<{ file: File; blob: Blob }> { + const response = await this.withTimeout( + fetch(videoUrl), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while loading the source video.", + ); + if (!response.ok) { + throw new Error( + `Failed to fetch source video: ${response.status} ${response.statusText}`, + ); + } + const blob = await this.withTimeout( + response.blob(), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while reading the source video.", + ); + const filename = videoUrl.split("/").pop() || "video"; + return { + blob, + file: new File([blob], filename, { type: blob.type }), }; } @@ -218,7 +279,8 @@ export class StreamingVideoDecoder { const { file } = await this.loadSourceFile(videoUrl); // Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds - const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href) + .href; this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); await this.withTimeout( this.demuxer.load(file), @@ -231,7 +293,9 @@ export class StreamingVideoDecoder { SOURCE_LOAD_TIMEOUT_MS, "Timed out while reading video metadata.", ); - const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video"); + const videoStream = mediaInfo.streams.find( + (s) => s.codec_type_string === "video", + ); let frameRate = 60; if (videoStream?.avg_frame_rate) { @@ -243,7 +307,9 @@ export class StreamingVideoDecoder { } } - const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio"); + const audioStream = mediaInfo.streams.find( + (s) => s.codec_type_string === "audio", + ); // Scan video packets to find the true content boundary. // MediaRecorder (especially on Linux) writes unreliable container durations. @@ -251,14 +317,23 @@ export class StreamingVideoDecoder { // Pass explicit range because some containers are truncated without one. // Sanitize because mediaInfo.duration can be NaN/Infinity (Chromium Linux bug), // which would propagate into demuxer.read() as an invalid endpoint. - const containerDurationSec = Number.isFinite(mediaInfo.duration) ? mediaInfo.duration : 0; + const containerDurationSec = Number.isFinite(mediaInfo.duration) + ? mediaInfo.duration + : 0; const streamDurationSec = - typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) + typeof videoStream?.duration === "number" && + Number.isFinite(videoStream.duration) ? videoStream.duration : 0; - const hintedDurationSec = Math.max(containerDurationSec, streamDurationSec, 0); + const hintedDurationSec = Math.max( + containerDurationSec, + streamDurationSec, + 0, + ); const scanEndSec = - hintedDurationSec > 0 ? hintedDurationSec + 0.5 : SCAN_UNBOUNDED_FALLBACK_SEC; + hintedDurationSec > 0 + ? hintedDurationSec + 0.5 + : SCAN_UNBOUNDED_FALLBACK_SEC; let maxPacketEndUs = 0; const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader(); try { @@ -276,14 +351,18 @@ export class StreamingVideoDecoder { } } const scannedDuration = maxPacketEndUs / 1_000_000; - const validatedDuration = validateDuration(mediaInfo.duration, scannedDuration); + const validatedDuration = validateDuration( + mediaInfo.duration, + scannedDuration, + ); this.metadata = { width: videoStream?.width || 1920, height: videoStream?.height || 1080, duration: validatedDuration, streamDuration: - typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) + typeof videoStream?.duration === "number" && + Number.isFinite(videoStream.duration) ? videoStream.duration : undefined, frameRate, @@ -316,8 +395,14 @@ export class StreamingVideoDecoder { const decoderConfig = await this.demuxer.getDecoderConfig("video"); - console.log("[StreamingVideoDecoder] decoderConfig.codec:", decoderConfig.codec); - console.log("[StreamingVideoDecoder] decoderConfig.description:", decoderConfig.description); + console.log( + "[StreamingVideoDecoder] decoderConfig.codec:", + decoderConfig.codec, + ); + console.log( + "[StreamingVideoDecoder] decoderConfig.description:", + decoderConfig.description, + ); // web-demuxer may return bare four-character code strings ("av01", "vp08", // "vp09", "avc1") that WebCodecs rejects. Normalize them to the short or @@ -356,7 +441,8 @@ export class StreamingVideoDecoder { const segmentOutputFrameCounts = segments.map((segment) => Math.ceil( - ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate, + ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * + targetFrameRate, ), ); const frameDurationUs = 1_000_000 / targetFrameRate; @@ -398,7 +484,9 @@ export class StreamingVideoDecoder { : decoderConfig; try { - const support = await VideoDecoder.isConfigSupported(preferredDecoderConfig); + const support = await VideoDecoder.isConfigSupported( + preferredDecoderConfig, + ); console.log( `[StreamingVideoDecoder] isConfigSupported for "${preferredDecoderConfig.codec}":`, support.supported, @@ -424,7 +512,8 @@ export class StreamingVideoDecoder { const getNextFrame = (): Promise => { if (decodeError) throw decodeError; - if (pendingFrames.length > 0) return Promise.resolve(pendingFrames.shift()!); + if (pendingFrames.length > 0) + return Promise.resolve(pendingFrames.shift()!); if (decodeDone) return Promise.resolve(null); return new Promise((resolve) => { frameResolve = resolve; @@ -488,11 +577,18 @@ export class StreamingVideoDecoder { if (segmentFrameIndex >= segmentFrameCount) return false; const sourceTimeSec = - segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed; + segment.startSec + + (segmentFrameIndex / targetFrameRate) * segment.speed; if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false; - const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); - await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); + const clone = new VideoFrame(heldFrame, { + timestamp: heldFrame.timestamp, + }); + await onFrame( + clone, + exportFrameIndex * frameDurationUs, + sourceTimeSec * 1000, + ); segmentFrameIndex++; exportFrameIndex++; return true; @@ -555,7 +651,8 @@ export class StreamingVideoDecoder { } const sourceTimeSec = - currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed; + currentSegment.startSec + + (segmentFrameIndex / targetFrameRate) * currentSegment.speed; if (sourceTimeSec >= currentSegment.endSec - EPSILON_SEC) { break; } @@ -563,8 +660,14 @@ export class StreamingVideoDecoder { break; } - const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); - await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); + const clone = new VideoFrame(heldFrame, { + timestamp: heldFrame.timestamp, + }); + await onFrame( + clone, + exportFrameIndex * frameDurationUs, + sourceTimeSec * 1000, + ); segmentFrameIndex++; exportFrameIndex++; } @@ -629,7 +732,9 @@ export class StreamingVideoDecoder { }) ) { const decodedAtLabel = - lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`; + lastDecodedFrameSec === null + ? "no decoded frame" + : `${lastDecodedFrameSec.toFixed(3)}s`; const message = `Decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s) – export may be slightly shorter than expected.`; console.warn(`[StreamingVideoDecoder] ${message}`); onWarning?.(message); @@ -679,7 +784,10 @@ export class StreamingVideoDecoder { speedRegions?: SpeedRegion[], ): { effectiveDuration: number; totalFrames: number } { if (!this.metadata) throw new Error("Must call loadMetadata() first"); - const trimSegments = this.computeSegments(this.metadata.duration, trimRegions); + const trimSegments = this.computeSegments( + this.metadata.duration, + trimRegions, + ); const segments = this.splitBySpeed(trimSegments, speedRegions); return { effectiveDuration: segments.reduce( @@ -688,7 +796,9 @@ export class StreamingVideoDecoder { ), totalFrames: segments.reduce((sum, seg) => { const segDur = seg.endSec - seg.startSec - EPSILON_SEC; - return sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)); + return ( + sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)) + ); }, 0), }; } @@ -704,10 +814,15 @@ export class StreamingVideoDecoder { if (!speedRegions || speedRegions.length === 0) return segments.map((s) => ({ ...s, speed: 1 })); - const result: Array<{ startSec: number; endSec: number; speed: number }> = []; + const result: Array<{ startSec: number; endSec: number; speed: number }> = + []; for (const segment of segments) { const overlapping = speedRegions - .filter((sr) => sr.startMs / 1000 < segment.endSec && sr.endMs / 1000 > segment.startSec) + .filter( + (sr) => + sr.startMs / 1000 < segment.endSec && + sr.endMs / 1000 > segment.startSec, + ) .sort((a, b) => a.startMs - b.startMs); if (overlapping.length === 0) { @@ -719,7 +834,8 @@ export class StreamingVideoDecoder { for (const sr of overlapping) { const srStart = Math.max(sr.startMs / 1000, segment.startSec); const srEnd = Math.min(sr.endMs / 1000, segment.endSec); - if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 }); + if (cursor < srStart) + result.push({ startSec: cursor, endSec: srStart, speed: 1 }); result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed }); cursor = srEnd; } @@ -763,9 +879,16 @@ export class StreamingVideoDecoder { } /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ - private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + private withTimeout( + promise: Promise, + timeoutMs: number, + message: string, + ): Promise { return new Promise((resolve, reject) => { - const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); + const timer = window.setTimeout( + () => reject(new Error(message)), + timeoutMs, + ); promise.then( (value) => { window.clearTimeout(timer); From db1d6bfe96cde771d106997da9b260537ce7e4ba Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 11:47:51 +0200 Subject: [PATCH 17/19] refactor: rewrite loadFileAsArrayBuffer using static loadLocalSourceFile/loadRemoteSourceFile Make loadLocalSourceFile and loadRemoteSourceFile static so they can be called without an instance. loadSourceFile applies withTimeout around each static call. loadFileAsArrayBuffer delegates to the same static methods and extracts ArrayBuffer + contentType from the blob, eliminating the duplicated IPC/fetch branching logic. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/exporter/streamingDecoder.ts | 66 +++++++++++----------------- 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index ac3dffb4..1b686e50 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -161,36 +161,24 @@ export function shouldFailDecodeEndedEarly({ } /** - * Loads a video file as an ArrayBuffer, using the Electron IPC bridge for - * local paths and falling back to `fetch` for remote / blob / data URLs. - * Also returns the `contentType` from the response headers (empty string for - * local IPC reads where no Content-Type is available). - * This is the single canonical place for reading raw video bytes in the renderer. + * Loads a video file as an ArrayBuffer, delegating to + * `StreamingVideoDecoder.loadLocalSourceFile` for local paths (Electron IPC) + * and `StreamingVideoDecoder.loadRemoteSourceFile` for remote / blob / data URLs. + * Also returns the `contentType` derived from the blob (empty string for local + * IPC reads where no Content-Type is available). */ export async function loadFileAsArrayBuffer( videoUrl: string, ): Promise<{ data: ArrayBuffer; contentType: string }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); - if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { - const result = await window.electronAPI.readBinaryFile(videoUrl); - if (!result.success || !result.data) { - throw new Error( - result.message ?? result.error ?? "Failed to read video file", - ); - } - return { data: result.data, contentType: "" }; + if (!isRemoteUrl && window.electronAPI) { + const { blob } = await StreamingVideoDecoder.loadLocalSourceFile(videoUrl); + return { data: await blob.arrayBuffer(), contentType: "" }; } - const response = await fetch(videoUrl); - if (!response.ok) { - throw new Error( - `Failed to fetch video file: ${response.status} ${response.statusText}`, - ); - } - const contentType = - response.headers.get("content-type")?.split(";")[0].trim() ?? ""; - return { data: await response.arrayBuffer(), contentType }; + const { blob } = await StreamingVideoDecoder.loadRemoteSourceFile(videoUrl); + return { data: await blob.arrayBuffer(), contentType: blob.type }; } /** Caller must close the VideoFrame after use. */ @@ -219,20 +207,24 @@ export class StreamingVideoDecoder { ): Promise<{ file: File; blob: Blob }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { - return this.loadLocalSourceFile(videoUrl); + return this.withTimeout( + StreamingVideoDecoder.loadLocalSourceFile(videoUrl), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while loading the source video.", + ); } - return this.loadRemoteSourceFile(videoUrl); + return this.withTimeout( + StreamingVideoDecoder.loadRemoteSourceFile(videoUrl), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while loading the source video.", + ); } /** Loads a local video file via the Electron IPC bridge. */ - private async loadLocalSourceFile( + static async loadLocalSourceFile( videoUrl: string, ): Promise<{ file: File; blob: Blob }> { - const result = await this.withTimeout( - window.electronAPI.readBinaryFile(videoUrl), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while loading the source video.", - ); + const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { throw new Error( result.message || result.error || "Failed to read source video", @@ -250,24 +242,16 @@ export class StreamingVideoDecoder { } /** Loads a remote or blob video URL via fetch. */ - private async loadRemoteSourceFile( + static async loadRemoteSourceFile( videoUrl: string, ): Promise<{ file: File; blob: Blob }> { - const response = await this.withTimeout( - fetch(videoUrl), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while loading the source video.", - ); + const response = await fetch(videoUrl); if (!response.ok) { throw new Error( `Failed to fetch source video: ${response.status} ${response.statusText}`, ); } - const blob = await this.withTimeout( - response.blob(), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while reading the source video.", - ); + const blob = await response.blob(); const filename = videoUrl.split("/").pop() || "video"; return { blob, From c0b929d4b2e7b4dba5be11c5ac8d2dbcd0012abc Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 11:53:44 +0200 Subject: [PATCH 18/19] refactor: simplify function signatures and reduce line breaks for readability --- src/lib/exporter/streamingDecoder.ts | 171 +++++++-------------------- 1 file changed, 40 insertions(+), 131 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 1b686e50..0e429cb6 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -19,11 +19,7 @@ function buildAV1CodecString(description?: BufferSource): string { const bytes = description instanceof ArrayBuffer ? new Uint8Array(description) - : new Uint8Array( - description.buffer, - description.byteOffset, - description.byteLength, - ); + : new Uint8Array(description.buffer, description.byteOffset, description.byteLength); // AV1CodecConfigurationRecord layout (4+ bytes): // Byte 0: marker (1) | version (7) @@ -91,24 +87,16 @@ const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60; * @param containerDuration Duration from the container-level metadata * @param scannedDuration Duration derived from actual packet timestamps (ground truth) */ -export function validateDuration( - containerDuration: number, - scannedDuration: number, -): number { +export function validateDuration(containerDuration: number, scannedDuration: number): number { if (scannedDuration <= 0) { // Zero scanned duration means corrupted/empty file — fall back to container // (downstream shouldFailDecodeEndedEarly will catch truly empty files) - return Number.isFinite(containerDuration) - ? Math.max(containerDuration, 0) - : 0; + return Number.isFinite(containerDuration) ? Math.max(containerDuration, 0) : 0; } if (!Number.isFinite(containerDuration) || containerDuration <= 0) { return scannedDuration; } - if ( - Math.abs(containerDuration - scannedDuration) > - DURATION_DIVERGENCE_THRESHOLD_SEC - ) { + if (Math.abs(containerDuration - scannedDuration) > DURATION_DIVERGENCE_THRESHOLD_SEC) { return scannedDuration; } return containerDuration; @@ -133,27 +121,16 @@ export function shouldFailDecodeEndedEarly({ return false; } - if ( - typeof streamDurationSec !== "number" || - !Number.isFinite(streamDurationSec) - ) { + if (typeof streamDurationSec !== "number" || !Number.isFinite(streamDurationSec)) { return true; } const metadataTailSec = requiredEndSec - streamDurationSec; const decodedNearStreamEnd = - Math.abs(lastDecodedFrameSec - streamDurationSec) <= - STREAM_DURATION_MATCH_TOLERANCE_SEC; - - const maxTailSec = Math.max( - METADATA_TAIL_TOLERANCE_SEC, - requiredEndSec * 0.01, - ); - if ( - decodedNearStreamEnd && - metadataTailSec > 0 && - metadataTailSec <= maxTailSec - ) { + Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC; + + const maxTailSec = Math.max(METADATA_TAIL_TOLERANCE_SEC, requiredEndSec * 0.01); + if (decodedNearStreamEnd && metadataTailSec > 0 && metadataTailSec <= maxTailSec) { return false; } @@ -202,9 +179,7 @@ export class StreamingVideoDecoder { private metadata: DecodedVideoInfo | null = null; /** Routes to the appropriate loader based on whether the source is local or remote. */ - private async loadSourceFile( - videoUrl: string, - ): Promise<{ file: File; blob: Blob }> { + private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { return this.withTimeout( @@ -221,14 +196,10 @@ export class StreamingVideoDecoder { } /** Loads a local video file via the Electron IPC bridge. */ - static async loadLocalSourceFile( - videoUrl: string, - ): Promise<{ file: File; blob: Blob }> { + static async loadLocalSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { - throw new Error( - result.message || result.error || "Failed to read source video", - ); + throw new Error(result.message || result.error || "Failed to read source video"); } const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; @@ -242,14 +213,10 @@ export class StreamingVideoDecoder { } /** Loads a remote or blob video URL via fetch. */ - static async loadRemoteSourceFile( - videoUrl: string, - ): Promise<{ file: File; blob: Blob }> { + static async loadRemoteSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const response = await fetch(videoUrl); if (!response.ok) { - throw new Error( - `Failed to fetch source video: ${response.status} ${response.statusText}`, - ); + throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`); } const blob = await response.blob(); const filename = videoUrl.split("/").pop() || "video"; @@ -263,8 +230,7 @@ export class StreamingVideoDecoder { const { file } = await this.loadSourceFile(videoUrl); // Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds - const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href) - .href; + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); await this.withTimeout( this.demuxer.load(file), @@ -277,9 +243,7 @@ export class StreamingVideoDecoder { SOURCE_LOAD_TIMEOUT_MS, "Timed out while reading video metadata.", ); - const videoStream = mediaInfo.streams.find( - (s) => s.codec_type_string === "video", - ); + const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video"); let frameRate = 60; if (videoStream?.avg_frame_rate) { @@ -291,9 +255,7 @@ export class StreamingVideoDecoder { } } - const audioStream = mediaInfo.streams.find( - (s) => s.codec_type_string === "audio", - ); + const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio"); // Scan video packets to find the true content boundary. // MediaRecorder (especially on Linux) writes unreliable container durations. @@ -301,23 +263,14 @@ export class StreamingVideoDecoder { // Pass explicit range because some containers are truncated without one. // Sanitize because mediaInfo.duration can be NaN/Infinity (Chromium Linux bug), // which would propagate into demuxer.read() as an invalid endpoint. - const containerDurationSec = Number.isFinite(mediaInfo.duration) - ? mediaInfo.duration - : 0; + const containerDurationSec = Number.isFinite(mediaInfo.duration) ? mediaInfo.duration : 0; const streamDurationSec = - typeof videoStream?.duration === "number" && - Number.isFinite(videoStream.duration) + typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) ? videoStream.duration : 0; - const hintedDurationSec = Math.max( - containerDurationSec, - streamDurationSec, - 0, - ); + const hintedDurationSec = Math.max(containerDurationSec, streamDurationSec, 0); const scanEndSec = - hintedDurationSec > 0 - ? hintedDurationSec + 0.5 - : SCAN_UNBOUNDED_FALLBACK_SEC; + hintedDurationSec > 0 ? hintedDurationSec + 0.5 : SCAN_UNBOUNDED_FALLBACK_SEC; let maxPacketEndUs = 0; const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader(); try { @@ -335,18 +288,14 @@ export class StreamingVideoDecoder { } } const scannedDuration = maxPacketEndUs / 1_000_000; - const validatedDuration = validateDuration( - mediaInfo.duration, - scannedDuration, - ); + const validatedDuration = validateDuration(mediaInfo.duration, scannedDuration); this.metadata = { width: videoStream?.width || 1920, height: videoStream?.height || 1080, duration: validatedDuration, streamDuration: - typeof videoStream?.duration === "number" && - Number.isFinite(videoStream.duration) + typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) ? videoStream.duration : undefined, frameRate, @@ -379,14 +328,8 @@ export class StreamingVideoDecoder { const decoderConfig = await this.demuxer.getDecoderConfig("video"); - console.log( - "[StreamingVideoDecoder] decoderConfig.codec:", - decoderConfig.codec, - ); - console.log( - "[StreamingVideoDecoder] decoderConfig.description:", - decoderConfig.description, - ); + console.log("[StreamingVideoDecoder] decoderConfig.codec:", decoderConfig.codec); + console.log("[StreamingVideoDecoder] decoderConfig.description:", decoderConfig.description); // web-demuxer may return bare four-character code strings ("av01", "vp08", // "vp09", "avc1") that WebCodecs rejects. Normalize them to the short or @@ -425,8 +368,7 @@ export class StreamingVideoDecoder { const segmentOutputFrameCounts = segments.map((segment) => Math.ceil( - ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * - targetFrameRate, + ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate, ), ); const frameDurationUs = 1_000_000 / targetFrameRate; @@ -468,9 +410,7 @@ export class StreamingVideoDecoder { : decoderConfig; try { - const support = await VideoDecoder.isConfigSupported( - preferredDecoderConfig, - ); + const support = await VideoDecoder.isConfigSupported(preferredDecoderConfig); console.log( `[StreamingVideoDecoder] isConfigSupported for "${preferredDecoderConfig.codec}":`, support.supported, @@ -496,8 +436,7 @@ export class StreamingVideoDecoder { const getNextFrame = (): Promise => { if (decodeError) throw decodeError; - if (pendingFrames.length > 0) - return Promise.resolve(pendingFrames.shift()!); + if (pendingFrames.length > 0) return Promise.resolve(pendingFrames.shift()!); if (decodeDone) return Promise.resolve(null); return new Promise((resolve) => { frameResolve = resolve; @@ -561,18 +500,13 @@ export class StreamingVideoDecoder { if (segmentFrameIndex >= segmentFrameCount) return false; const sourceTimeSec = - segment.startSec + - (segmentFrameIndex / targetFrameRate) * segment.speed; + segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed; if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false; const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp, }); - await onFrame( - clone, - exportFrameIndex * frameDurationUs, - sourceTimeSec * 1000, - ); + await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); segmentFrameIndex++; exportFrameIndex++; return true; @@ -635,8 +569,7 @@ export class StreamingVideoDecoder { } const sourceTimeSec = - currentSegment.startSec + - (segmentFrameIndex / targetFrameRate) * currentSegment.speed; + currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed; if (sourceTimeSec >= currentSegment.endSec - EPSILON_SEC) { break; } @@ -647,11 +580,7 @@ export class StreamingVideoDecoder { const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp, }); - await onFrame( - clone, - exportFrameIndex * frameDurationUs, - sourceTimeSec * 1000, - ); + await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); segmentFrameIndex++; exportFrameIndex++; } @@ -716,9 +645,7 @@ export class StreamingVideoDecoder { }) ) { const decodedAtLabel = - lastDecodedFrameSec === null - ? "no decoded frame" - : `${lastDecodedFrameSec.toFixed(3)}s`; + lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`; const message = `Decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s) – export may be slightly shorter than expected.`; console.warn(`[StreamingVideoDecoder] ${message}`); onWarning?.(message); @@ -768,10 +695,7 @@ export class StreamingVideoDecoder { speedRegions?: SpeedRegion[], ): { effectiveDuration: number; totalFrames: number } { if (!this.metadata) throw new Error("Must call loadMetadata() first"); - const trimSegments = this.computeSegments( - this.metadata.duration, - trimRegions, - ); + const trimSegments = this.computeSegments(this.metadata.duration, trimRegions); const segments = this.splitBySpeed(trimSegments, speedRegions); return { effectiveDuration: segments.reduce( @@ -780,9 +704,7 @@ export class StreamingVideoDecoder { ), totalFrames: segments.reduce((sum, seg) => { const segDur = seg.endSec - seg.startSec - EPSILON_SEC; - return ( - sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)) - ); + return sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)); }, 0), }; } @@ -798,15 +720,10 @@ export class StreamingVideoDecoder { if (!speedRegions || speedRegions.length === 0) return segments.map((s) => ({ ...s, speed: 1 })); - const result: Array<{ startSec: number; endSec: number; speed: number }> = - []; + const result: Array<{ startSec: number; endSec: number; speed: number }> = []; for (const segment of segments) { const overlapping = speedRegions - .filter( - (sr) => - sr.startMs / 1000 < segment.endSec && - sr.endMs / 1000 > segment.startSec, - ) + .filter((sr) => sr.startMs / 1000 < segment.endSec && sr.endMs / 1000 > segment.startSec) .sort((a, b) => a.startMs - b.startMs); if (overlapping.length === 0) { @@ -818,8 +735,7 @@ export class StreamingVideoDecoder { for (const sr of overlapping) { const srStart = Math.max(sr.startMs / 1000, segment.startSec); const srEnd = Math.min(sr.endMs / 1000, segment.endSec); - if (cursor < srStart) - result.push({ startSec: cursor, endSec: srStart, speed: 1 }); + if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 }); result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed }); cursor = srEnd; } @@ -863,16 +779,9 @@ export class StreamingVideoDecoder { } /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ - private withTimeout( - promise: Promise, - timeoutMs: number, - message: string, - ): Promise { + private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { return new Promise((resolve, reject) => { - const timer = window.setTimeout( - () => reject(new Error(message)), - timeoutMs, - ); + const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); promise.then( (value) => { window.clearTimeout(timer); From 3a2f90722c7e6224fd482cc1cc2aeb850f643ea9 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 12:08:55 +0200 Subject: [PATCH 19/19] chore: restore original VideoFrame formatting in streamingDecoder.ts Biome had reformatted two pre-existing VideoFrame constructor calls from single-line to multi-line style when --write was run on the file. Restore them to match main so the diff only reflects our intentional refactoring. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/exporter/streamingDecoder.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 0e429cb6..752d5cd4 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -503,9 +503,7 @@ export class StreamingVideoDecoder { segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed; if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false; - const clone = new VideoFrame(heldFrame, { - timestamp: heldFrame.timestamp, - }); + const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); segmentFrameIndex++; exportFrameIndex++; @@ -577,9 +575,7 @@ export class StreamingVideoDecoder { break; } - const clone = new VideoFrame(heldFrame, { - timestamp: heldFrame.timestamp, - }); + const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); segmentFrameIndex++; exportFrameIndex++;