Fix the OPFS output lifecycle (locked stream on close)

StreamTarget closes the underlying writer when the output is finalized —
that is also what commits an OPFS file — so closing it ourselves threw
"Can not close locked stream". finalize() now simply reads the committed
file back, and every failure path (invalid attempt, encode error,
validation failure) aborts the write and deletes the temporary entry.
This commit is contained in:
2026-09-17 21:01:15 -05:00
parent bd812bb601
commit e7657a3dd0
2 changed files with 44 additions and 15 deletions
+26 -4
View File
@@ -1,6 +1,11 @@
import { StreamTarget, type StreamTargetChunk } from "mediabunny"; import { StreamTarget, type StreamTargetChunk } from "mediabunny";
import { createStoredFile, opfsSupported, purgeStaleStoredFiles } from "./opfs"; import {
createStoredFile,
deleteStoredFile,
opfsSupported,
purgeStaleStoredFiles,
} from "./opfs";
interface CollectedChunk { interface CollectedChunk {
blob: Blob; blob: Blob;
@@ -11,6 +16,7 @@ interface CollectedChunk {
export interface StreamOutput { export interface StreamOutput {
target: StreamTarget; target: StreamTarget;
finalize(type: string): Promise<{ blob: Blob; storageKey?: string }>; finalize(type: string): Promise<{ blob: Blob; storageKey?: string }>;
abort(): Promise<void>;
} }
/** /**
@@ -19,8 +25,9 @@ export interface StreamOutput {
* *
* Preferred: a real Origin Private File System file with random access, which * Preferred: a real Origin Private File System file with random access, which
* is what the muxer expects (it rewrites earlier byte ranges for headers). * is what the muxer expects (it rewrites earlier byte ranges for headers).
* Fallback: collect chunks and assemble them, with the newest write winning * StreamTarget closes the underlying writer when the output is finalized,
* per byte range. * which also commits the OPFS file. Fallback: collect chunks and assemble
* them, with the newest write winning per byte range.
*/ */
export async function createStreamOutput( export async function createStreamOutput(
extension: string, extension: string,
@@ -30,16 +37,28 @@ export async function createStreamOutput(
if (stored) { if (stored) {
void purgeStaleStoredFiles(); void purgeStaleStoredFiles();
const writable = await stored.handle.createWritable(); const writable = await stored.handle.createWritable();
let finalized = false;
return { return {
target: new StreamTarget(writable), target: new StreamTarget(writable),
finalize: async (type: string) => { finalize: async (type: string) => {
await writable.close(); finalized = true;
// The writer is already closed and the file committed; just read it.
const file = await stored.handle.getFile(); const file = await stored.handle.getFile();
return { return {
blob: new File([file], stored.name, { type }), blob: new File([file], stored.name, { type }),
storageKey: stored.name, storageKey: stored.name,
}; };
}, },
abort: async () => {
if (!finalized) {
try {
await writable.abort();
} catch {
// Locked or already closed — the entry removal still applies.
}
}
await deleteStoredFile(stored.name);
},
}; };
} }
} }
@@ -66,6 +85,9 @@ function createBufferOutput(): StreamOutput {
finalize: async (type: string) => ({ finalize: async (type: string) => ({
blob: assembleBlob(chunks, type), blob: assembleBlob(chunks, type),
}), }),
abort: async () => {
chunks.length = 0;
},
}; };
} }
+7
View File
@@ -115,6 +115,7 @@ export async function optimizeVideo(
break; break;
} }
input.dispose?.(); input.dispose?.();
await stream.abort();
} }
if (winner) break; if (winner) break;
} }
@@ -132,6 +133,7 @@ export async function optimizeVideo(
try { try {
await winner.conversion.execute(); await winner.conversion.execute();
} catch (error) { } catch (error) {
await winner.stream.abort();
throw new Error( throw new Error(
`Video encoding failed: ${ `Video encoding failed: ${
error instanceof Error ? error.message : "unknown error" error instanceof Error ? error.message : "unknown error"
@@ -142,6 +144,7 @@ export async function optimizeVideo(
} }
report(0.97, "Checking the result…"); report(0.97, "Checking the result…");
try {
const { blob, storageKey } = await winner.stream.finalize( const { blob, storageKey } = await winner.stream.finalize(
container === "webm" ? "video/webm" : "video/mp4", container === "webm" ? "video/webm" : "video/mp4",
); );
@@ -153,6 +156,10 @@ export async function optimizeVideo(
note: `${winner.codec} · ${container}`, note: `${winner.codec} · ${container}`,
storageKey, storageKey,
}; };
} catch (error) {
await winner.stream.abort();
throw error;
}
} }
/** Reject a truncated or structurally broken muxer output up front. */ /** Reject a truncated or structurally broken muxer output up front. */