Write video output to a real OPFS file instead of assembling chunks

The hand-rolled chunk assembly was verified correct in Node but still
produced a broken MP4 in the browser, so stop relying on it: the muxer now
streams into an Origin Private File System file (random access is exactly
what MP4 needs), the worker returns the OPFS File directly, and the entry
is deleted after a successful apply (stale ones are purged hourly). The
chunk collector remains only as a fallback for browsers without OPFS.

The result is validated before it reaches the UI: MP4s must contain a
moov box and WebM files must start with the EBML magic, so a broken muxer
output surfaces as an error instead of a 0x0 preview.
This commit is contained in:
2026-09-17 20:58:26 -05:00
parent 9162cf23ce
commit bd812bb601
7 changed files with 181 additions and 16 deletions
+43 -10
View File
@@ -1,21 +1,52 @@
import { StreamTarget, type StreamTargetChunk } from "mediabunny";
import { createStoredFile, opfsSupported, purgeStaleStoredFiles } from "./opfs";
interface CollectedChunk {
blob: Blob;
position: number;
sequence: number;
}
/**
* Stream the muxed output into Blob parts instead of one giant ArrayBuffer —
* a 4K file would otherwise blow the tab's memory. Writes may arrive out of
* order (the muxer patches headers), so the newest write wins per byte range
* when the final blob is assembled.
*/
export function createStreamOutput(): {
export interface StreamOutput {
target: StreamTarget;
toBlob: (type: string) => Blob;
} {
finalize(type: string): Promise<{ blob: Blob; storageKey?: string }>;
}
/**
* Stream the muxed output somewhere that is not one giant ArrayBuffer — a 4K
* file would otherwise blow the tab's memory.
*
* 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.
*/
export async function createStreamOutput(
extension: string,
): Promise<StreamOutput> {
if (opfsSupported()) {
const stored = await createStoredFile(extension);
if (stored) {
void purgeStaleStoredFiles();
const writable = await stored.handle.createWritable();
return {
target: new StreamTarget(writable),
finalize: async (type: string) => {
await writable.close();
const file = await stored.handle.getFile();
return {
blob: new File([file], stored.name, { type }),
storageKey: stored.name,
};
},
};
}
}
return createBufferOutput();
}
function createBufferOutput(): StreamOutput {
const chunks: CollectedChunk[] = [];
let sequence = 0;
const writable = new WritableStream<StreamTargetChunk>({
@@ -32,7 +63,9 @@ export function createStreamOutput(): {
chunked: true,
chunkSize: 8 * 1024 * 1024,
}),
toBlob: (type: string) => assembleBlob(chunks, type),
finalize: async (type: string) => ({
blob: assembleBlob(chunks, type),
}),
};
}