From e7657a3dd04a9f3515222bf5896ab5195c16077e Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 21:01:15 -0500 Subject: [PATCH] Fix the OPFS output lifecycle (locked stream on close) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/features/optimize/stream.ts | 30 ++++++++++++++++++++---- frontend/src/features/optimize/video.ts | 29 ++++++++++++++--------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/frontend/src/features/optimize/stream.ts b/frontend/src/features/optimize/stream.ts index fb9a53d..51338f8 100644 --- a/frontend/src/features/optimize/stream.ts +++ b/frontend/src/features/optimize/stream.ts @@ -1,6 +1,11 @@ import { StreamTarget, type StreamTargetChunk } from "mediabunny"; -import { createStoredFile, opfsSupported, purgeStaleStoredFiles } from "./opfs"; +import { + createStoredFile, + deleteStoredFile, + opfsSupported, + purgeStaleStoredFiles, +} from "./opfs"; interface CollectedChunk { blob: Blob; @@ -11,6 +16,7 @@ interface CollectedChunk { export interface StreamOutput { target: StreamTarget; finalize(type: string): Promise<{ blob: Blob; storageKey?: string }>; + abort(): Promise; } /** @@ -19,8 +25,9 @@ export interface StreamOutput { * * Preferred: a real Origin Private File System file with random access, which * is what the muxer expects (it rewrites earlier byte ranges for headers). - * Fallback: collect chunks and assemble them, with the newest write winning - * per byte range. + * StreamTarget closes the underlying writer when the output is finalized, + * which also commits the OPFS file. Fallback: collect chunks and assemble + * them, with the newest write winning per byte range. */ export async function createStreamOutput( extension: string, @@ -30,16 +37,28 @@ export async function createStreamOutput( if (stored) { void purgeStaleStoredFiles(); const writable = await stored.handle.createWritable(); + let finalized = false; return { target: new StreamTarget(writable), 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(); return { blob: new File([file], stored.name, { type }), 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) => ({ blob: assembleBlob(chunks, type), }), + abort: async () => { + chunks.length = 0; + }, }; } diff --git a/frontend/src/features/optimize/video.ts b/frontend/src/features/optimize/video.ts index 79d0200..dd1e242 100644 --- a/frontend/src/features/optimize/video.ts +++ b/frontend/src/features/optimize/video.ts @@ -115,6 +115,7 @@ export async function optimizeVideo( break; } input.dispose?.(); + await stream.abort(); } if (winner) break; } @@ -132,6 +133,7 @@ export async function optimizeVideo( try { await winner.conversion.execute(); } catch (error) { + await winner.stream.abort(); throw new Error( `Video encoding failed: ${ error instanceof Error ? error.message : "unknown error" @@ -142,17 +144,22 @@ export async function optimizeVideo( } report(0.97, "Checking the result…"); - const { blob, storageKey } = await winner.stream.finalize( - container === "webm" ? "video/webm" : "video/mp4", - ); - await validateMedia(blob, container); - return { - blob, - width: outputWidth, - height: outputHeight, - note: `${winner.codec} · ${container}`, - storageKey, - }; + try { + const { blob, storageKey } = await winner.stream.finalize( + container === "webm" ? "video/webm" : "video/mp4", + ); + await validateMedia(blob, container); + return { + blob, + width: outputWidth, + height: outputHeight, + note: `${winner.codec} · ${container}`, + storageKey, + }; + } catch (error) { + await winner.stream.abort(); + throw error; + } } /** Reject a truncated or structurally broken muxer output up front. */