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. */