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 <noreply@anthropic.com>
This commit is contained in:
Davide Mendolia
2026-05-23 19:04:35 +02:00
co-authored by Claude Sonnet 4.6
parent 9a72f31148
commit 928cabc68d
2 changed files with 40 additions and 6 deletions
+25 -4
View File
@@ -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<Float32Array> {
function computePeaksInWorker(
audioBuffer: AudioBuffer,
signal?: AbortSignal,
): Promise<Float32Array> {
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<Float32Array> {
}
worker.onmessage = (e: MessageEvent<Float32Array>) => {
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]);
+15 -2
View File
@@ -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" }),