- The streaming assembler applied chunks sorted by position, so header patches written last could be overwritten by earlier data. Chunks now apply in arrival order (newest write wins per byte range) and are only laid out by position afterwards. - The Optimize modal now reads the media metadata itself: both previews show resolution and duration, and the processed video is flagged in red when it comes out shorter than the original (a truncated encode is no longer something you have to guess by file size).
236 lines
6.5 KiB
TypeScript
236 lines
6.5 KiB
TypeScript
import {
|
||
ALL_FORMATS,
|
||
Conversion,
|
||
Input,
|
||
Mp4OutputFormat,
|
||
Output,
|
||
Quality,
|
||
StreamTarget,
|
||
UrlSource,
|
||
WebMOutputFormat,
|
||
type StreamTargetChunk,
|
||
type VideoCodec,
|
||
} from "mediabunny";
|
||
|
||
import {
|
||
extensionOf,
|
||
type OptimizeOptions,
|
||
type OptimizeResult,
|
||
type ProgressReporter,
|
||
} from "./types";
|
||
|
||
const CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = {
|
||
mp4: ["avc", "hevc"],
|
||
webm: ["vp9", "av1"],
|
||
};
|
||
|
||
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<StreamTargetChunk>({
|
||
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,
|
||
filename: string,
|
||
options: OptimizeOptions,
|
||
report: ProgressReporter,
|
||
): Promise<OptimizeResult> {
|
||
if (typeof VideoEncoder === "undefined") {
|
||
throw new Error(
|
||
"This browser does not expose WebCodecs (VideoEncoder) inside workers.",
|
||
);
|
||
}
|
||
const extension = extensionOf(filename);
|
||
report(0.03, "Reading the video…");
|
||
const probe = new Input({
|
||
source: new UrlSource(url, { maxCacheSize: 4 * 1024 * 1024 }),
|
||
formats: ALL_FORMATS,
|
||
});
|
||
const track = await probe.getPrimaryVideoTrack();
|
||
if (!track) {
|
||
throw new Error("No video track found in this file.");
|
||
}
|
||
const sourceWidth = await track.getDisplayWidth();
|
||
const sourceHeight = await track.getDisplayHeight();
|
||
probe.dispose?.();
|
||
|
||
const container: "mp4" | "webm" =
|
||
options.container === "keep"
|
||
? extension === "webm"
|
||
? "webm"
|
||
: "mp4"
|
||
: options.container;
|
||
const format =
|
||
container === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
|
||
|
||
const outputHeight = even(options.height ?? sourceHeight);
|
||
const outputWidth = even(
|
||
Math.round(sourceWidth * (outputHeight / sourceHeight)),
|
||
);
|
||
const quality = new Quality(options.qualityLevel);
|
||
const candidates: VideoCodec[] =
|
||
options.codec === "auto" ? CODEC_CANDIDATES[container] : [options.codec];
|
||
const preferences: ("prefer-hardware" | "no-preference")[] =
|
||
options.preferHardware ? ["prefer-hardware", "no-preference"] : ["no-preference"];
|
||
|
||
report(0.06, "Preparing the encoder…");
|
||
let winner:
|
||
| {
|
||
conversion: Conversion;
|
||
input: Input;
|
||
stream: ReturnType<typeof createStreamOutput>;
|
||
codec: VideoCodec;
|
||
}
|
||
| null = null;
|
||
const tried: string[] = [];
|
||
for (const codec of candidates) {
|
||
for (const preference of preferences) {
|
||
tried.push(`${codec}${preference === "prefer-hardware" ? " (hw)" : ""}`);
|
||
const input = new Input({
|
||
source: new UrlSource(url, { maxCacheSize: 4 * 1024 * 1024 }),
|
||
formats: ALL_FORMATS,
|
||
});
|
||
const stream = createStreamOutput();
|
||
const output = new Output({ format, target: stream.target });
|
||
const attempt = await Conversion.init({
|
||
input,
|
||
output,
|
||
video: {
|
||
codec,
|
||
quality,
|
||
width: outputWidth,
|
||
height: outputHeight,
|
||
fit: "contain",
|
||
hardwareAcceleration: preference,
|
||
forceTranscode: true,
|
||
},
|
||
});
|
||
if (attempt.isValid) {
|
||
winner = { conversion: attempt, input, stream, codec };
|
||
break;
|
||
}
|
||
input.dispose?.();
|
||
}
|
||
if (winner) break;
|
||
}
|
||
if (!winner) {
|
||
throw new Error(
|
||
`This browser could not configure ${container.toUpperCase()} encoding for ` +
|
||
`${outputWidth}×${outputHeight} (tried ${tried.join(", ")}). ` +
|
||
"Try another codec, container or resolution.",
|
||
);
|
||
}
|
||
|
||
winner.conversion.onProgress = (progress: number) => {
|
||
report(0.08 + Math.min(Math.max(progress, 0), 1) * 0.85, "Encoding…");
|
||
};
|
||
try {
|
||
await winner.conversion.execute();
|
||
} catch (error) {
|
||
throw new Error(
|
||
`Video encoding failed: ${
|
||
error instanceof Error ? error.message : "unknown error"
|
||
}`,
|
||
);
|
||
} finally {
|
||
winner.input.dispose?.();
|
||
}
|
||
|
||
report(0.97, "Packaging…");
|
||
return {
|
||
blob: winner.stream.toBlob(container === "webm" ? "video/webm" : "video/mp4"),
|
||
width: outputWidth,
|
||
height: outputHeight,
|
||
note: `${winner.codec} · ${container}`,
|
||
};
|
||
}
|