From 928cabc68df85909b3bd1164f0b031acb8c93c8a Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 19:04:35 +0200 Subject: [PATCH] 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" }),