Detect a missing video track and retry with another encoder
Your symptom (audio kept, video 0x0) means the hardware H.264 encode silently produced no video samples while the audio was copied. The result check now parses the moov box and requires a 'vide' handler, so an audio-only output is treated as a failed attempt: the pipeline walks all codec x hardware/software configurations (starting with hardware when enabled) and only reports an error when every one fails, naming what each attempt returned. Resizing is also skipped entirely unless a smaller height was requested, keeping the scaler out of the pipeline.
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Structural checks for muxer output. Kept dependency-free so it can also be
|
||||||
|
* exercised outside the browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function readUint32(view: DataView, offset: number): number {
|
||||||
|
return view.getUint32(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readType(view: DataView, offset: number): string {
|
||||||
|
return String.fromCharCode(
|
||||||
|
view.getUint8(offset),
|
||||||
|
view.getUint8(offset + 1),
|
||||||
|
view.getUint8(offset + 2),
|
||||||
|
view.getUint8(offset + 3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
type: string;
|
||||||
|
start: number; // content start
|
||||||
|
size: number; // content size
|
||||||
|
}
|
||||||
|
|
||||||
|
function childBoxes(view: DataView, start: number, end: number): Box[] {
|
||||||
|
const boxes: Box[] = [];
|
||||||
|
let offset = start;
|
||||||
|
while (offset + 8 <= end) {
|
||||||
|
const size = readUint32(view, offset);
|
||||||
|
const type = readType(view, offset + 4);
|
||||||
|
let boxSize = size;
|
||||||
|
let headerSize = 8;
|
||||||
|
if (size === 1 && offset + 16 <= end) {
|
||||||
|
boxSize = Number(view.getBigUint64(offset + 8));
|
||||||
|
headerSize = 16;
|
||||||
|
} else if (size === 0) {
|
||||||
|
boxSize = end - offset;
|
||||||
|
}
|
||||||
|
if (boxSize < headerSize || offset + boxSize > end) break;
|
||||||
|
boxes.push({
|
||||||
|
type,
|
||||||
|
start: offset + headerSize,
|
||||||
|
size: boxSize - headerSize,
|
||||||
|
});
|
||||||
|
offset += boxSize;
|
||||||
|
}
|
||||||
|
return boxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the moov payload declares a video track (hdlr === 'vide'). */
|
||||||
|
export function moovHasVideoTrack(
|
||||||
|
view: DataView,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
): boolean {
|
||||||
|
for (const trak of childBoxes(view, start, end)) {
|
||||||
|
if (trak.type !== "trak") continue;
|
||||||
|
for (const mdia of childBoxes(view, trak.start, trak.start + trak.size)) {
|
||||||
|
if (mdia.type !== "mdia") continue;
|
||||||
|
for (const hdlr of childBoxes(view, mdia.start, mdia.start + mdia.size)) {
|
||||||
|
if (hdlr.type !== "hdlr") continue;
|
||||||
|
const at = hdlr.start + 8; // version/flags + predefined
|
||||||
|
if (at + 4 <= hdlr.start + hdlr.size) {
|
||||||
|
if (readType(view, at) === "vide") return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws when a muxed file is truncated or has no usable video track. */
|
||||||
|
export async function assertPlayable(
|
||||||
|
file: Blob,
|
||||||
|
container: "mp4" | "webm",
|
||||||
|
): Promise<void> {
|
||||||
|
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
|
||||||
|
if (container === "webm") {
|
||||||
|
const magic = [0x1a, 0x45, 0xdf, 0xa3];
|
||||||
|
if (!magic.every((byte, index) => head[index] === byte)) {
|
||||||
|
throw new Error("the muxer produced an invalid WebM file");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let offset = 0;
|
||||||
|
for (let guard = 0; guard < 64 && offset + 8 <= file.size; guard++) {
|
||||||
|
const header = new Uint8Array(
|
||||||
|
await file.slice(offset, offset + 16).arrayBuffer(),
|
||||||
|
);
|
||||||
|
const view = new DataView(
|
||||||
|
header.buffer,
|
||||||
|
header.byteOffset,
|
||||||
|
header.byteLength,
|
||||||
|
);
|
||||||
|
const size = readUint32(view, 0);
|
||||||
|
const type = readType(view, 4);
|
||||||
|
let boxSize = size;
|
||||||
|
let headerSize = 8;
|
||||||
|
if (size === 1 && header.length >= 16) {
|
||||||
|
boxSize = Number(view.getBigUint64(8));
|
||||||
|
headerSize = 16;
|
||||||
|
} else if (size === 0) {
|
||||||
|
boxSize = file.size - offset;
|
||||||
|
}
|
||||||
|
if (type === "moov") {
|
||||||
|
const payload = new Uint8Array(
|
||||||
|
await file.slice(offset + headerSize, offset + boxSize).arrayBuffer(),
|
||||||
|
);
|
||||||
|
if (!moovHasVideoTrack(new DataView(payload.buffer), 0, payload.length)) {
|
||||||
|
throw new Error("the result has no video track");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!boxSize || boxSize < 8) break;
|
||||||
|
offset += boxSize;
|
||||||
|
}
|
||||||
|
throw new Error("the muxer produced an invalid MP4 file (no moov box)");
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type VideoCodec,
|
type VideoCodec,
|
||||||
} from "mediabunny";
|
} from "mediabunny";
|
||||||
|
|
||||||
|
import { assertPlayable } from "./mp4";
|
||||||
import { createStreamOutput, type StreamOutput } from "./stream";
|
import { createStreamOutput, type StreamOutput } from "./stream";
|
||||||
import {
|
import {
|
||||||
extensionOf,
|
extensionOf,
|
||||||
@@ -23,10 +24,21 @@ const CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = {
|
|||||||
webm: ["vp9", "av1"],
|
webm: ["vp9", "av1"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PREFERENCE_LABEL: Record<string, string> = {
|
||||||
|
"prefer-hardware": "hardware",
|
||||||
|
"prefer-software": "software",
|
||||||
|
"no-preference": "auto",
|
||||||
|
};
|
||||||
|
|
||||||
function even(value: number): number {
|
function even(value: number): number {
|
||||||
return Math.max(2, value - (value % 2));
|
return Math.max(2, value - (value % 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Attempt {
|
||||||
|
codec: VideoCodec;
|
||||||
|
preference: "prefer-hardware" | "prefer-software";
|
||||||
|
}
|
||||||
|
|
||||||
/** Transcode with WebCodecs, streaming the source and the result. */
|
/** Transcode with WebCodecs, streaming the source and the result. */
|
||||||
export async function optimizeVideo(
|
export async function optimizeVideo(
|
||||||
url: string,
|
url: string,
|
||||||
@@ -62,10 +74,17 @@ export async function optimizeVideo(
|
|||||||
const format =
|
const format =
|
||||||
container === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
|
container === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
|
||||||
|
|
||||||
const outputHeight = even(options.height ?? sourceHeight);
|
// Only resize when a smaller height is requested — leaving it out keeps the
|
||||||
|
// scaler (and its failure modes) out of the pipeline entirely.
|
||||||
|
const downscale =
|
||||||
|
options.height !== null && options.height < sourceHeight
|
||||||
|
? even(options.height)
|
||||||
|
: null;
|
||||||
|
const outputHeight = downscale ?? even(sourceHeight);
|
||||||
const outputWidth = even(
|
const outputWidth = even(
|
||||||
Math.round(sourceWidth * (outputHeight / sourceHeight)),
|
Math.round(sourceWidth * (outputHeight / sourceHeight)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefer bitrate-based encoding: hardware encoders often ignore the
|
// Prefer bitrate-based encoding: hardware encoders often ignore the
|
||||||
// per-frame quantizer that qualitative presets would otherwise use, which
|
// per-frame quantizer that qualitative presets would otherwise use, which
|
||||||
// made results inconsistent (and sometimes tiny).
|
// made results inconsistent (and sometimes tiny).
|
||||||
@@ -73,136 +92,98 @@ export async function optimizeVideo(
|
|||||||
quality: options.qualityLevel,
|
quality: options.qualityLevel,
|
||||||
preferBitrate: true,
|
preferBitrate: true,
|
||||||
});
|
});
|
||||||
const candidates: VideoCodec[] =
|
const codecs: VideoCodec[] =
|
||||||
options.codec === "auto" ? CODEC_CANDIDATES[container] : [options.codec];
|
options.codec === "auto" ? CODEC_CANDIDATES[container] : [options.codec];
|
||||||
const preferences: ("prefer-hardware" | "no-preference")[] =
|
const preferences: Attempt["preference"][] = options.preferHardware
|
||||||
options.preferHardware ? ["prefer-hardware", "no-preference"] : ["no-preference"];
|
? ["prefer-hardware", "prefer-software"]
|
||||||
|
: ["prefer-software"];
|
||||||
report(0.06, "Preparing the encoder…");
|
const attempts: Attempt[] = [];
|
||||||
let winner:
|
for (const codec of codecs) {
|
||||||
| {
|
|
||||||
conversion: Conversion;
|
|
||||||
input: Input;
|
|
||||||
stream: StreamOutput;
|
|
||||||
codec: VideoCodec;
|
|
||||||
}
|
|
||||||
| null = null;
|
|
||||||
const tried: string[] = [];
|
|
||||||
for (const codec of candidates) {
|
|
||||||
for (const preference of preferences) {
|
for (const preference of preferences) {
|
||||||
tried.push(`${codec}${preference === "prefer-hardware" ? " (hw)" : ""}`);
|
attempts.push({ codec, preference });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues: string[] = [];
|
||||||
|
for (const [index, attempt] of attempts.entries()) {
|
||||||
|
const label = `${attempt.codec} (${PREFERENCE_LABEL[attempt.preference]})`;
|
||||||
|
report(
|
||||||
|
0.06 + index * 0.01,
|
||||||
|
index === 0 ? "Preparing the encoder…" : `Retrying with ${label}…`,
|
||||||
|
);
|
||||||
const input = new Input({
|
const input = new Input({
|
||||||
source: new UrlSource(url, { maxCacheSize: 4 * 1024 * 1024 }),
|
source: new UrlSource(url, { maxCacheSize: 4 * 1024 * 1024 }),
|
||||||
formats: ALL_FORMATS,
|
formats: ALL_FORMATS,
|
||||||
});
|
});
|
||||||
const stream = await createStreamOutput(container);
|
const stream: StreamOutput = await createStreamOutput(container);
|
||||||
const output = new Output({ format, target: stream.target });
|
const output = new Output({ format, target: stream.target });
|
||||||
const attempt = await Conversion.init({
|
let conversion: Conversion;
|
||||||
|
try {
|
||||||
|
conversion = await Conversion.init({
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
video: {
|
video: {
|
||||||
codec,
|
codec: attempt.codec,
|
||||||
quality,
|
quality,
|
||||||
width: outputWidth,
|
...(downscale !== null
|
||||||
height: outputHeight,
|
? { width: outputWidth, height: outputHeight, fit: "contain" }
|
||||||
fit: "contain",
|
: {}),
|
||||||
hardwareAcceleration: preference,
|
hardwareAcceleration: attempt.preference,
|
||||||
forceTranscode: true,
|
forceTranscode: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (attempt.isValid) {
|
} catch (error) {
|
||||||
winner = { conversion: attempt, input, stream, codec };
|
issues.push(`${label}: ${messageOf(error)}`);
|
||||||
break;
|
|
||||||
}
|
|
||||||
input.dispose?.();
|
input.dispose?.();
|
||||||
await stream.abort();
|
await stream.abort();
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (winner) break;
|
if (!conversion.isValid) {
|
||||||
}
|
issues.push(`${label}: unsupported configuration`);
|
||||||
if (!winner) {
|
input.dispose?.();
|
||||||
throw new Error(
|
await stream.abort();
|
||||||
`This browser could not configure ${container.toUpperCase()} encoding for ` +
|
continue;
|
||||||
`${outputWidth}×${outputHeight} (tried ${tried.join(", ")}). ` +
|
|
||||||
"Try another codec, container or resolution.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
winner.conversion.onProgress = (progress: number) => {
|
conversion.onProgress = (progress: number) => {
|
||||||
report(0.08 + Math.min(Math.max(progress, 0), 1) * 0.85, "Encoding…");
|
report(0.08 + Math.min(Math.max(progress, 0), 1) * 0.85, "Encoding…");
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await winner.conversion.execute();
|
await conversion.execute();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await winner.stream.abort();
|
issues.push(`${label}: ${messageOf(error)}`);
|
||||||
throw new Error(
|
input.dispose?.();
|
||||||
`Video encoding failed: ${
|
await stream.abort();
|
||||||
error instanceof Error ? error.message : "unknown error"
|
continue;
|
||||||
}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
winner.input.dispose?.();
|
|
||||||
}
|
}
|
||||||
|
input.dispose?.();
|
||||||
|
|
||||||
report(0.97, "Checking the result…");
|
report(0.96, "Checking the result…");
|
||||||
try {
|
try {
|
||||||
const { blob, storageKey } = await winner.stream.finalize(
|
const { blob, storageKey } = await stream.finalize(
|
||||||
container === "webm" ? "video/webm" : "video/mp4",
|
container === "webm" ? "video/webm" : "video/mp4",
|
||||||
);
|
);
|
||||||
await validateMedia(blob, container);
|
await assertPlayable(blob, container);
|
||||||
|
const software = attempt.preference === "prefer-software";
|
||||||
return {
|
return {
|
||||||
blob,
|
blob,
|
||||||
width: outputWidth,
|
width: outputWidth,
|
||||||
height: outputHeight,
|
height: outputHeight,
|
||||||
note: `${winner.codec} · ${container}`,
|
note: `${attempt.codec} · ${container}${software ? " · software" : ""}`,
|
||||||
storageKey,
|
storageKey,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await winner.stream.abort();
|
issues.push(`${label}: ${messageOf(error)}`);
|
||||||
throw error;
|
await stream.abort();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Video encoding failed on every configuration (${issues.join("; ")}). ` +
|
||||||
|
"Try WebM output or a lower resolution.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reject a truncated or structurally broken muxer output up front. */
|
function messageOf(error: unknown): string {
|
||||||
async function validateMedia(
|
return error instanceof Error ? error.message : "unknown error";
|
||||||
file: Blob,
|
|
||||||
container: "mp4" | "webm",
|
|
||||||
): Promise<void> {
|
|
||||||
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
|
|
||||||
if (container === "webm") {
|
|
||||||
const magic = [0x1a, 0x45, 0xdf, 0xa3];
|
|
||||||
if (!magic.every((byte, index) => head[index] === byte)) {
|
|
||||||
throw new Error("The muxer produced an invalid WebM file.");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Walk the top-level MP4 boxes until a moov box is found.
|
|
||||||
let offset = 0;
|
|
||||||
for (let guard = 0; guard < 64 && offset + 8 <= file.size; guard++) {
|
|
||||||
const header = new Uint8Array(
|
|
||||||
await file.slice(offset, offset + 16).arrayBuffer(),
|
|
||||||
);
|
|
||||||
const size =
|
|
||||||
((header[0] << 24) | (header[1] << 16) | (header[2] << 8) | header[3]) >>>
|
|
||||||
0;
|
|
||||||
const type = String.fromCharCode(
|
|
||||||
header[4],
|
|
||||||
header[5],
|
|
||||||
header[6],
|
|
||||||
header[7],
|
|
||||||
);
|
|
||||||
if (type === "moov") return;
|
|
||||||
let boxSize = size;
|
|
||||||
if (size === 1 && header.length >= 16) {
|
|
||||||
boxSize = Number(
|
|
||||||
new DataView(header.buffer, header.byteOffset, header.byteLength).getBigUint64(
|
|
||||||
8,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!boxSize || boxSize < 8) break;
|
|
||||||
offset += boxSize;
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
"The muxer produced an invalid MP4 file (no moov box found).",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user