diff --git a/frontend/src/features/optimize/stream.ts b/frontend/src/features/optimize/stream.ts new file mode 100644 index 0000000..2f73186 --- /dev/null +++ b/frontend/src/features/optimize/stream.ts @@ -0,0 +1,93 @@ +import { StreamTarget, type StreamTargetChunk } from "mediabunny"; + +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(): { + target: StreamTarget; + toBlob: (type: string) => Blob; +} { + const chunks: CollectedChunk[] = []; + let sequence = 0; + const writable = new WritableStream({ + write(chunk) { + chunks.push({ + blob: new Blob([chunk.data.slice()]), + position: chunk.position, + sequence: sequence++, + }); + }, + }); + return { + target: new StreamTarget(writable, { + chunked: true, + chunkSize: 8 * 1024 * 1024, + }), + toBlob: (type: string) => assembleBlob(chunks, type), + }; +} + +export function assembleBlob(chunks: CollectedChunk[], type: string): Blob { + if (chunks.length === 0) { + throw new Error("Encoding produced no output."); + } + // Apply writes in arrival order — the newest write wins per byte range — + // and only then lay the segments out by position. + const ordered = [...chunks].sort((a, b) => a.sequence - b.sequence); + + interface Segment { + start: number; + end: number; + sequence: number; + blob: Blob; + } + let segments: Segment[] = []; + for (const chunk of ordered) { + const start = chunk.position; + const end = start + chunk.blob.size; + const next: Segment[] = []; + for (const segment of segments) { + if (segment.end <= start || segment.start >= end) { + next.push(segment); + continue; + } + if (segment.start < start) { + next.push({ + ...segment, + end: start, + blob: segment.blob.slice(0, start - segment.start), + }); + } + if (segment.end > end) { + next.push({ + ...segment, + start: end, + blob: segment.blob.slice(end - segment.start), + }); + } + } + next.push({ start, end, sequence: chunk.sequence, blob: chunk.blob }); + segments = next; + } + segments.sort((a, b) => a.start - b.start); + + const parts: Blob[] = []; + let cursor = 0; + for (const segment of segments) { + if (segment.start !== cursor) { + throw new Error("The muxer produced an incomplete file."); + } + parts.push(segment.blob); + cursor = segment.end; + } + return new Blob(parts, { type }); +} diff --git a/frontend/src/features/optimize/video.ts b/frontend/src/features/optimize/video.ts index 558a57f..d3e4608 100644 --- a/frontend/src/features/optimize/video.ts +++ b/frontend/src/features/optimize/video.ts @@ -5,13 +5,12 @@ import { Mp4OutputFormat, Output, Quality, - StreamTarget, UrlSource, WebMOutputFormat, - type StreamTargetChunk, type VideoCodec, } from "mediabunny"; +import { createStreamOutput } from "./stream"; import { extensionOf, type OptimizeOptions, @@ -28,95 +27,6 @@ function even(value: number): number { return Math.max(2, value - (value % 2)); } -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. Chunks may arrive out of - * order (the muxer patches headers), so later writes win per byte range when - * the final blob is assembled. - */ -function createStreamOutput(): { - target: StreamTarget; - toBlob: (type: string) => Blob; -} { - const chunks: CollectedChunk[] = []; - let sequence = 0; - const writable = new WritableStream({ - write(chunk) { - chunks.push({ - blob: new Blob([chunk.data.slice()]), - position: chunk.position, - sequence: sequence++, - }); - }, - }); - return { - target: new StreamTarget(writable, { - chunked: true, - chunkSize: 8 * 1024 * 1024, - }), - toBlob: (type: string) => assembleBlob(chunks, type), - }; -} - -function assembleBlob(chunks: CollectedChunk[], type: string): Blob { - if (chunks.length === 0) { - throw new Error("Encoding produced no output."); - } - // The muxer may rewrite earlier ranges (header patching), so apply writes - // in arrival order — the newest write wins per byte range — and only then - // lay the segments out by position. - const ordered = [...chunks].sort((a, b) => a.sequence - b.sequence); - - interface Segment { - start: number; - end: number; - sequence: number; - blob: Blob; - } - let segments: Segment[] = []; - for (const chunk of ordered) { - const start = chunk.position; - const end = start + chunk.blob.size; - const next: Segment[] = []; - for (const segment of segments) { - if (segment.end <= start || segment.start >= end) { - next.push(segment); - continue; - } - if (segment.start < start) { - next.push({ ...segment, end: start }); - } - if (segment.end > end) { - next.push({ - ...segment, - start: end, - blob: segment.blob.slice(end - segment.start), - }); - } - } - next.push({ start, end, sequence: chunk.sequence, blob: chunk.blob }); - segments = next; - } - segments.sort((a, b) => a.start - b.start); - - const parts: Blob[] = []; - let cursor = 0; - for (const segment of segments) { - if (segment.start !== cursor) { - throw new Error("The muxer produced an incomplete file."); - } - parts.push(segment.blob); - cursor = segment.end; - } - return new Blob(parts, { type }); -} - /** Transcode with WebCodecs, streaming the source and the result. */ export async function optimizeVideo( url: string,