Files
hyperframes/packages/engine/src/utils/ffprobe.test.ts
T
James Russo 2339757377 fix: bound HDR and video extraction resources (#2955)
* fix: bound HDR and video extraction resources

* fix: trim negative video extraction preroll

* fix: skip invisible video extraction windows

* fix: preserve negative-start loop and held tails

* fix: cap finite video slots to source duration

* fix: bound held-tail frame extraction

* fix: plan from playable video duration

* fix: preserve open-ended held video tails

* fix: resolve held tails from decoded frames

* fix: normalize final-frame probe timestamps

* fix: handle unseekable final-frame sources

* fix: dedupe final-frame probes per render

* refactor: clarify output dynamic range contract
2026-08-02 20:21:54 -07:00

1193 lines
40 KiB
TypeScript

// fallow-ignore-file code-duplication
import { EventEmitter } from "events";
import { readFileSync } from "fs";
import { basename, resolve } from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
extractMediaMetadata,
extractPngMetadataFromBuffer,
parseFrameRate,
pixelFormatHasAlpha,
} from "./ffprobe.js";
function crc32(buf: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc ^= buf[i] ?? 0;
for (let bit = 0; bit < 8; bit++) {
const mask = -(crc & 1);
crc = (crc >>> 1) ^ (0xedb88320 & mask);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: number[]): Buffer {
const chunkData = Buffer.from(data);
const header = Buffer.alloc(8);
header.writeUInt32BE(chunkData.length, 0);
header.write(type, 4, 4, "ascii");
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), chunkData])), 0);
return Buffer.concat([header, chunkData, crc]);
}
function buildPngWithChunks(chunks: Buffer[]): Buffer {
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), ...chunks]);
}
function buildMinimalPng(options?: {
cIcpAfterIdat?: boolean;
invalidCrc?: boolean;
longCicp?: boolean;
}) {
const ihdr = pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]);
const cicpData = options?.longCicp ? [9, 16, 0, 1, 255] : [9, 16, 0, 1];
let cicp = pngChunk("cICP", cicpData);
if (options?.invalidCrc) {
cicp = Buffer.from(cicp);
cicp[cicp.length - 1] ^= 0xff;
}
const idat = pngChunk(
"IDAT",
[0x78, 0x9c, 0x63, 0x60, 0x60, 0x60, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01],
);
const iend = pngChunk("IEND", []);
return options?.cIcpAfterIdat
? buildPngWithChunks([ihdr, idat, cicp, iend])
: buildPngWithChunks([ihdr, cicp, idat, iend]);
}
describe("extractMediaMetadata", () => {
it("reads HDR PNG cICP metadata when ffprobe color fields are absent", async () => {
const fixturePath = resolve(
__dirname,
"../../../producer/tests/hdr-regression/src/hdr-photo-pq.png",
);
const metadata = await extractMediaMetadata(fixturePath);
expect(metadata.colorSpace).toEqual({
colorPrimaries: "bt2020",
colorTransfer: "smpte2084",
colorSpace: "gbr",
});
});
});
describe("extractPngMetadataFromBuffer", () => {
it("accepts a valid cICP chunk before IDAT", () => {
const metadata = extractPngMetadataFromBuffer(buildMinimalPng());
expect(metadata?.colorSpace).toEqual({
colorPrimaries: "bt2020",
colorTransfer: "smpte2084",
colorSpace: "gbr",
});
});
it("rejects cICP chunks after IDAT", () => {
const metadata = extractPngMetadataFromBuffer(buildMinimalPng({ cIcpAfterIdat: true }));
expect(metadata).toEqual({
width: 1,
height: 1,
colorSpace: null,
});
});
it("rejects cICP chunks with invalid CRC", () => {
expect(extractPngMetadataFromBuffer(buildMinimalPng({ invalidCrc: true }))).toBeNull();
});
it("rejects cICP chunks whose payload is not exactly four bytes", () => {
const metadata = extractPngMetadataFromBuffer(buildMinimalPng({ longCicp: true }));
expect(metadata).toEqual({
width: 1,
height: 1,
colorSpace: null,
});
});
it("continues to parse the checked-in HDR PNG fixture", () => {
const fixture = readFileSync(
resolve(__dirname, "../../../producer/tests/hdr-regression/src/hdr-photo-pq.png"),
);
expect(extractPngMetadataFromBuffer(fixture)?.colorSpace?.colorTransfer).toBe("smpte2084");
});
});
interface SpawnCall {
command: string;
args: readonly string[];
}
interface FakeProc extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
type SpawnOutcome =
| { kind: "missing" }
| { kind: "error"; message: string; code?: string }
| {
kind: "exit";
code: number;
stdout?: string;
stderr?: string;
/** Emit stdout as these exact byte chunks, to exercise a multi-byte
* character split across a pipe-chunk boundary. */
stdoutChunks?: Buffer[];
};
function createSpawnSpy(outcomes: SpawnOutcome[]): {
spawn: (command: string, args: readonly string[]) => FakeProc;
calls: SpawnCall[];
} {
const calls: SpawnCall[] = [];
let invocation = 0;
const spawn = (command: string, args: readonly string[]): FakeProc => {
calls.push({ command, args });
const outcome = outcomes[invocation] ?? outcomes[outcomes.length - 1];
invocation += 1;
const proc = new EventEmitter() as FakeProc;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
process.nextTick(() => {
if (!outcome) return;
if (outcome.kind === "missing") {
const err = new Error("spawn ffprobe ENOENT") as NodeJS.ErrnoException;
err.code = "ENOENT";
proc.emit("error", err);
return;
}
if (outcome.kind === "error") {
const err = new Error(outcome.message) as NodeJS.ErrnoException;
if (outcome.code) err.code = outcome.code;
proc.emit("error", err);
return;
}
if (outcome.stdoutChunks) {
for (const chunk of outcome.stdoutChunks) proc.stdout.emit("data", chunk);
} else if (outcome.stdout) proc.stdout.emit("data", Buffer.from(outcome.stdout));
if (outcome.stderr) proc.stderr.emit("data", Buffer.from(outcome.stderr));
proc.emit("close", outcome.code);
});
return proc;
};
return { spawn, calls };
}
describe("ffprobe missing-binary fallback", () => {
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
const originalPath = process.env.PATH;
function hidePathBinaries(): void {
process.env.PATH = "";
}
afterEach(() => {
vi.resetModules();
vi.doUnmock("child_process");
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
});
it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => {
process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
const successfulStderr = "recoverable diagnostic on a successful probe";
const { spawn, calls } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }],
format: { duration: "1.25", bit_rate: "128000" },
}),
stderr: successfulStderr,
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata } = await import("./ffprobe.js");
const meta = await extractAudioMetadata("/tmp/uses-configured-ffprobe.wav");
expect(meta.durationSeconds).toBe(1.25);
expect(JSON.stringify(meta)).not.toContain(successfulStderr);
expect(calls[0]?.command).toBe(resolve("/tools/ffprobe.exe"));
expect(calls[0]?.args.slice(0, 2)).toEqual(["-v", "error"]);
});
// `profile` matters now: the packet refinement is an allowlist on AAC-LC,
// because the 1024-sample formula is wrong for LD/ELD/HE and unverified for
// the rest. An unprofiled "aac" stream deliberately keeps its container
// duration rather than being refined on an assumption.
it.each([
{
name: "non-AAC metadata",
codec: "mp3",
profile: undefined,
packets: undefined,
expected: 1.25,
calls: 1,
},
{
name: "unprofiled AAC",
codec: "aac",
profile: undefined,
packets: "783",
expected: 1.25,
calls: 1,
},
{
name: "valid AAC-LC packet count",
codec: "aac",
profile: "LC",
packets: "783",
expected: 16.704,
calls: 2,
},
{
name: "missing AAC packet count",
codec: "aac",
profile: "LC",
packets: undefined,
expected: 1.25,
calls: 2,
},
{
name: "zero AAC packet count",
codec: "aac",
profile: "LC",
packets: "0",
expected: 1.25,
calls: 2,
},
{
name: "invalid AAC packet count",
codec: "aac",
profile: "LC",
packets: "invalid",
expected: 1.25,
calls: 2,
},
])(
"derives audio duration for $name",
async ({ codec, profile, packets, expected, calls: expectedCalls }) => {
const outcomes: SpawnOutcome[] = [
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "audio",
codec_name: codec,
sample_rate: "48000",
channels: 2,
profile,
},
],
format: { duration: "1.25", bit_rate: "128000" },
}),
},
];
if (codec === "aac" && profile === "LC") {
outcomes.push({
kind: "exit",
code: 0,
stdout: JSON.stringify({ streams: [{ nb_read_packets: packets }], format: {} }),
});
}
const { spawn, calls } = createSpawnSpy(outcomes);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata } = await import("./ffprobe.js");
const meta = await extractAudioMetadata(`/tmp/${codec}-${packets ?? "none"}.audio`);
expect(meta.durationSeconds).toBeCloseTo(expected, 6);
expect(calls).toHaveLength(expectedCalls);
},
);
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
const fixture = resolve(
__dirname,
"../../../producer/tests/hdr-regression/src/hdr-photo-pq.png",
);
const meta = await extractMediaMetadataMocked(fixture);
expect(calls.length).toBe(1);
expect(basename(calls[0]?.command ?? "")).toMatch(/^ffprobe(?:\.exe)?$/);
expect(meta.videoCodec).toBe("png");
expect(meta.durationSeconds).toBe(0);
expect(meta.fps).toBe(0);
expect(meta.hasAudio).toBe(false);
expect(meta.isVFR).toBe(false);
expect(meta.hasAlpha).toBe(false);
expect(meta.colorSpace?.colorTransfer).toBe("smpte2084");
expect(meta.colorSpace?.colorPrimaries).toBe("bt2020");
});
it("extractMediaMetadata detects VP9 alpha_mode streams", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "vp9",
width: 320,
height: 180,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
pix_fmt: "yuv420p",
tags: { alpha_mode: "1" },
},
],
format: { duration: "1.5" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
const meta = await extractMediaMetadataMocked("/tmp/alpha.webm");
expect(meta.videoCodec).toBe("vp9");
expect(meta.hasAlpha).toBe(true);
});
it("normalizes omitted video color components to empty strings", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 64,
height: 64,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
pix_fmt: "yuv420p",
color_space: "bt709",
},
],
format: { duration: "1" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
const metadata = await extractMediaMetadataMocked("/tmp/partial-color.mp4");
expect(metadata.colorSpace).toEqual({
colorPrimaries: "",
colorTransfer: "",
colorSpace: "bt709",
});
});
// Regression: newer libavformat builds (and the output of `hyperframes
// remove-background` itself) write the VP9-alpha sidecar tag as
// `ALPHA_MODE` (uppercase). The lowercase-only check classified those
// files as having no alpha, the producer extracted them as JPGs, and
// the injected <img> overlays were fully opaque rectangles that hid
// every static element below them on the z-stack. The bug was silent —
// studio preview rendered correctly via native <video> playback while
// production renders covered headlines and captions with the avatar.
it("extractMediaMetadata detects ALPHA_MODE (uppercase) streams from newer ffmpeg builds", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "vp9",
width: 320,
height: 180,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
pix_fmt: "yuv420p",
tags: { ALPHA_MODE: "1" },
},
],
format: { duration: "1.5" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
const meta = await extractMediaMetadataMocked("/tmp/alpha-uppercase.webm");
expect(meta.videoCodec).toBe("vp9");
expect(meta.hasAlpha).toBe(true);
});
it("extractMediaMetadata rethrows ffprobe-missing error for non-image files without fallback", async () => {
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
await expect(extractMediaMetadataMocked("/tmp/no-such-video.mp4")).rejects.toThrow(/ffprobe/);
});
it("surfaces bounded ffprobe stderr for invalid media", async () => {
const leadingNoise = "x".repeat(10_000);
const diagnostic = "Invalid data found when processing input";
const inputPath = "/tmp/render/My Secret Video.mp4";
const { spawn, calls } = createSpawnSpy([
{
kind: "exit",
code: 1,
stderr: `${leadingNoise}${inputPath}: ${diagnostic}`,
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata } = await import("./ffprobe.js");
let thrown: unknown;
try {
await extractAudioMetadata(inputPath);
} catch (error) {
thrown = error;
}
expect(String(thrown)).toContain(diagnostic);
expect(String(thrown)).toContain("[input]");
expect(String(thrown)).not.toContain(inputPath);
expect(String(thrown)).not.toContain("My Secret Video.mp4");
expect(String(thrown).length).toBeLessThan(4_500);
expect(calls[0]?.args.slice(0, 2)).toEqual(["-v", "error"]);
expect(calls[0]?.args.at(-1)).toBe(inputPath);
});
it("redacts an input path fragment when the stderr tail starts inside its basename", async () => {
const diagnostic = "Invalid data found when processing input";
const inputPath = "/tmp/render/Confidential Client Preview.mp4";
const retainedPathFragment = "Client Preview.mp4";
const diagnosticPrefix = `: ${diagnostic} `;
const remainingBytes =
8 * 1024 - Buffer.byteLength(retainedPathFragment) - Buffer.byteLength(diagnosticPrefix);
const multibyteCount = Math.floor(remainingBytes / Buffer.byteLength("€"));
const trailingAscii = "x".repeat(remainingBytes - multibyteCount * Buffer.byteLength("€"));
const stderr = `${inputPath}${diagnosticPrefix}${"€".repeat(multibyteCount)}${trailingAscii}`;
const { spawn } = createSpawnSpy([{ kind: "exit", code: 1, stderr }]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata } = await import("./ffprobe.js");
let thrown: unknown;
try {
await extractAudioMetadata(inputPath);
} catch (error) {
thrown = error;
}
expect(String(thrown)).toContain(diagnostic);
expect(String(thrown)).toContain("[input]");
expect(String(thrown)).not.toContain(retainedPathFragment);
expect(String(thrown)).not.toContain("Client Preview.mp4");
});
it("extractAudioMetadata surfaces a ffprobe-missing error verbatim", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata } = await import("./ffprobe.js");
await expect(extractAudioMetadata("/tmp/no-such-audio.wav")).rejects.toThrow(
/ffprobe not found/,
);
expect(calls.length).toBe(1);
expect(basename(calls[0]?.command ?? "")).toMatch(/^ffprobe(?:\.exe)?$/);
});
it("analyzeKeyframeIntervals surfaces a ffprobe-missing error verbatim", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { analyzeKeyframeIntervals } = await import("./ffprobe.js");
await expect(analyzeKeyframeIntervals("/tmp/no-such-video.mp4")).rejects.toThrow(
/ffprobe not found/,
);
expect(calls.length).toBe(1);
expect(basename(calls[0]?.command ?? "")).toMatch(/^ffprobe(?:\.exe)?$/);
});
it("ffprobe-missing error message includes install hint", async () => {
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata } = await import("./ffprobe.js");
await expect(extractAudioMetadata("/tmp/example.mp3")).rejects.toThrow(/install FFmpeg/i);
});
});
describe("ffprobe option separator", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("child_process");
});
it("places -- before the file path so paths starting with - are not parsed as options", async () => {
const { spawn, calls } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 320,
height: 180,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
},
],
format: { duration: "1.5" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata } = await import("./ffprobe.js");
const filePath = "/tmp/-dangerous-name.mp4";
await extractMediaMetadata(filePath);
const args = calls[0]?.args ?? [];
const filePathIndex = args.indexOf(filePath);
expect(filePathIndex).toBeGreaterThan(0);
expect(args[filePathIndex - 1]).toBe("--");
});
it("uses -- for audio and keyframe probes too", async () => {
const { spawn, calls } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "audio",
codec_name: "aac",
// LC, so the packet-count refinement actually runs and its
// argv is covered here too.
profile: "LC",
sample_rate: "48000",
channels: 2,
},
],
format: { duration: "1.25" },
}),
},
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [{ nb_read_packets: "783" }],
format: {},
}),
},
{ kind: "exit", code: 0, stdout: "0.000\n1.000\n" },
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata, analyzeKeyframeIntervals } = await import("./ffprobe.js");
await extractAudioMetadata("/tmp/-audio.wav");
await analyzeKeyframeIntervals("/tmp/-video.mp4");
// Per call, not a flattened count. A total of 3 is satisfied by one call
// emitting three `--` and two emitting none — i.e. it cannot fail for
// misplacement, which is the shape of bug this exists to catch.
expect(calls.map((call) => (call.args ?? []).slice(-2))).toEqual([
["--", "/tmp/-audio.wav"],
["--", "/tmp/-audio.wav"],
["--", "/tmp/-video.mp4"],
]);
});
});
describe("parseFrameRate", () => {
// Direct against the exported function. The previous table drove this
// through extractMediaMetadata behind a spawn mock, which cost a
// vi.resetModules() plus a dynamic re-import of core's 238-file barrel per
// row (74.9 ms vs 0.094 ms) — and 4 of its 7 rows produced identical values
// against the pre-fix implementation, so it could not fail for the bugs it
// was written to catch.
it.each([
["30/1", 30],
["30000/1001", 29.97],
["24000/1001", 23.98],
["60", 60],
["25.5", 25.5],
])("parses %s as %s", (input, expected) => {
expect(parseFrameRate(input)).toBe(expected);
});
it.each([
["30/", 0],
["30/0", 0],
["0/0", 0],
["abc/def", 0],
["", 0],
[undefined, 0],
])("returns 0 for unusable input %s", (input, expected) => {
expect(parseFrameRate(input)).toBe(expected);
});
// Finite operands, infinite quotient — the operand-only guard missed these.
it.each(["1e308/1e-10", "2/1e-320"])("returns 0 for overflowing quotient %s", (input) => {
expect(parseFrameRate(input)).toBe(0);
});
// Negatives were truthy, so `meta.fps || 30` did not rescue them and
// buildEncoderArgs emitted `-r -30`.
it.each(["-30/1", "30/-1", "-60"])("returns 0 for negative rate %s", (input) => {
expect(parseFrameRate(input)).toBe(0);
});
// Fell through to a bare parseFloat that stops at trailing garbage. The
// rational operands had the same defect after the plain path was fixed.
it.each(["30/1/2", "60fps", "60fps/1", "60/1fps", "30garbage/1garbage", "/", "/1", "30/"])(
"returns 0 for malformed input %s",
(input) => {
expect(parseFrameRate(input)).toBe(0);
},
);
// raw * 100 overflows for a finite-but-huge rate, so the rounded value was
// Infinity even though the pre-round guard passed.
it.each(["1e307", "1e307/1", "1e308/0.5"])("returns 0 when rounding overflows: %s", (input) => {
expect(parseFrameRate(input)).toBe(0);
});
// 2dp rounding collapsed these to 0, and the caller's `|| 30` then
// re-encoded a 300-second timelapse as a ~1/30-second clip.
it.each([
["1/300", 0.01],
["1/1000", 0.01],
["1/200", 0.01],
])("floors sub-0.005 rate %s to %s rather than 0", (input, expected) => {
expect(parseFrameRate(input)).toBe(expected);
});
});
describe("extractPngMetadataFromBuffer cICP ordering", () => {
it("does not emit color space until IHDR provides width and height", () => {
const ihdr = pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]);
const cicp = pngChunk("cICP", [9, 16, 0, 1]);
const iend = pngChunk("IEND", []);
// cICP before IHDR is invalid PNG ordering; make sure we don't return
// zero-sized metadata in that case.
const malformed = buildPngWithChunks([cicp, ihdr, iend]);
expect(extractPngMetadataFromBuffer(malformed)).toEqual({
width: 1,
height: 1,
colorSpace: {
colorPrimaries: "bt2020",
colorTransfer: "smpte2084",
colorSpace: "gbr",
},
});
// Without any IHDR, a cICP alone should not produce a result.
const onlyCicp = buildPngWithChunks([cicp, iend]);
expect(extractPngMetadataFromBuffer(onlyCicp)).toBeNull();
});
});
describe("PNG chunk walk — integrity of the fallback itself", () => {
const IHDR_4K = [0, 0, 0x0f, 0, 0, 0, 0x08, 0x70, 16, 2, 0, 0, 0];
const CICP_PQ = [9, 16, 0, 1];
// Regression: the walk used to continue past cICP to IEND, which made
// whole-file integrity a precondition for returning anything. A damaged
// trailing chunk in an otherwise-good HDR PNG nulled the whole result, and
// extractMediaMetadata then re-throws the ffprobe error it had swallowed
// rather than using the fallback it just computed.
it("returns metadata even when a chunk AFTER cICP is corrupt", () => {
const bad = pngChunk("tEXt", [65, 66]);
bad[bad.length - 1] ^= 0xff; // break the CRC
const png = buildPngWithChunks([
pngChunk("IHDR", IHDR_4K),
pngChunk("cICP", CICP_PQ),
pngChunk("IDAT", [0x78, 0x9c, 0x63, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01]),
bad,
pngChunk("IEND", []),
]);
expect(extractPngMetadataFromBuffer(png)).toEqual({
width: 3840,
height: 2160,
colorSpace: { colorPrimaries: "bt2020", colorTransfer: "smpte2084", colorSpace: "gbr" },
});
});
it("survives outright truncation after cICP", () => {
const png = buildPngWithChunks([pngChunk("IHDR", IHDR_4K), pngChunk("cICP", CICP_PQ)]);
const truncated = Buffer.concat([png, Buffer.from([0, 0, 0x7f, 0xff, 73, 68, 65, 84])]);
expect(extractPngMetadataFromBuffer(truncated)?.width).toBe(3840);
});
// Regression: IHDR had no first-chunk anchor, so a later one overwrote the
// real dimensions and the producer laid out a 1-pixel image.
it("ignores a second IHDR", () => {
const png = buildPngWithChunks([
pngChunk("IHDR", IHDR_4K),
pngChunk("cICP", CICP_PQ),
pngChunk("IDAT", [0x78, 0x9c, 0x63, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01]),
pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]),
pngChunk("IEND", []),
]);
const meta = extractPngMetadataFromBuffer(png);
expect(meta?.width).toBe(3840);
expect(meta?.height).toBe(2160);
});
// A truncated 8-byte IHDR used to be accepted, reading height out of the
// CRC bytes; the spec length is 13.
it("rejects a short IHDR rather than reading garbage dimensions", () => {
const png = buildPngWithChunks([
pngChunk("IHDR", [0, 0, 0, 7, 0, 0, 0, 9]),
pngChunk("cICP", CICP_PQ),
pngChunk("IEND", []),
]);
expect(extractPngMetadataFromBuffer(png)).toBeNull();
});
it("still rejects a PNG whose IHDR or cICP itself is corrupt", () => {
const badIhdr = pngChunk("IHDR", IHDR_4K);
badIhdr[badIhdr.length - 1] ^= 0xff;
expect(
extractPngMetadataFromBuffer(buildPngWithChunks([badIhdr, pngChunk("IEND", [])])),
).toBeNull();
});
});
describe("crc32 works on every runtime the package declares", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("node:zlib");
});
/** Load ffprobe.ts as it would evaluate on Node 22.0/22.1. */
async function loadWithoutNativeCrc32() {
const actual = await vi.importActual<typeof import("node:zlib")>("node:zlib");
vi.resetModules();
// zlib.crc32 landed in 22.2.0, but engine and cli both declare
// `"node": ">=22"` behind a major-only gate. A NAMED import of a missing
// export throws at module evaluation, so ffprobe.ts would fail to load
// entirely on those runtimes — before any PNG is touched.
vi.doMock("node:zlib", () => ({ ...actual, crc32: undefined }));
return import("./ffprobe.js");
}
it("parses an HDR PNG identically with the native crc32 unavailable", async () => {
const png = buildPngWithChunks([
pngChunk("IHDR", [0, 0, 0x0f, 0, 0, 0, 0x08, 0x70, 16, 2, 0, 0, 0]),
pngChunk("cICP", [9, 16, 0, 1]),
pngChunk("IEND", []),
]);
const withNative = extractPngMetadataFromBuffer(png);
expect(withNative?.colorSpace?.colorTransfer).toBe("smpte2084");
const fresh = await loadWithoutNativeCrc32();
expect(fresh.extractPngMetadataFromBuffer(png)).toEqual(withNative);
});
it("rejects a corrupt chunk on the fallback path too", async () => {
const bad = pngChunk("IHDR", [0, 0, 0x0f, 0, 0, 0, 0x08, 0x70, 16, 2, 0, 0, 0]);
bad[bad.length - 1] ^= 0xff;
const png = buildPngWithChunks([bad, pngChunk("IEND", [])]);
const fresh = await loadWithoutNativeCrc32();
expect(fresh.extractPngMetadataFromBuffer(png)).toBeNull();
});
});
describe("pix_fmt alpha detection", () => {
// The old pattern's (^|[^a-z]) anchor bound to `yuva` alone, and the list
// omitted formats real files actually use.
const ALPHA = [
"yuva420p",
"rgba",
"argb",
"bgra",
"abgr",
"gbrap",
"ya8",
"ya16le",
"ayuv64le",
"yuva444p12le",
];
const OPAQUE = ["yuv420p", "rgb24", "gray", "gbrp", "nv12", "yuv444p10le", "bgr0", "rgb0"];
it.each(ALPHA)("detects alpha in %s", (fmt) => expect(pixelFormatHasAlpha(fmt)).toBe(true));
it.each(OPAQUE)("reports %s as opaque", (fmt) => expect(pixelFormatHasAlpha(fmt)).toBe(false));
});
describe("AAC duration refinement must never fail or distort the call", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("child_process");
});
const aacStream = (profile?: string) =>
JSON.stringify({
streams: [
{ codec_type: "audio", codec_name: "aac", sample_rate: "44100", channels: 2, profile },
],
format: { duration: "600", bit_rate: "128000" },
});
async function probe(outcomes: SpawnOutcome[], file: string) {
const { spawn, calls } = createSpawnSpy(outcomes);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata } = await import("./ffprobe.js");
return { meta: await extractAudioMetadata(file), calls };
}
// Regression: the refinement had no try/catch, so its failure rejected a
// call whose duration was already correct. htmlCompiler catches that as
// "no audio stream", returns 0, and the render ships silent.
it("keeps the container duration when the packet probe fails", async () => {
const { meta } = await probe(
[
{ kind: "exit", code: 0, stdout: aacStream("LC") },
{ kind: "exit", code: 1, stdout: "", stderr: "ffprobe exploded" },
],
"/tmp/aac-packet-probe-fails.m4a",
);
expect(meta.durationSeconds).toBe(600);
});
it("keeps the container duration when the packet probe returns junk", async () => {
const { meta } = await probe(
[
{ kind: "exit", code: 0, stdout: aacStream("LC") },
{ kind: "exit", code: 0, stdout: "not json at all" },
],
"/tmp/aac-packet-probe-junk.m4a",
);
expect(meta.durationSeconds).toBe(600);
});
// Regression: codec_name is "aac" for HE-AAC too, but its packets carry
// 2048 output samples — assuming 1024 halved a 10:00 podcast to 5:00.
it.each([
"HE-AAC",
"HE-AACv2",
"he-aac",
// 512- and 480-sample framing: the 1024 multiplier overstates these by
// 2x and ~2.13x, overwriting an already-correct container duration.
"LD",
"ELD",
// Not verified for this maths, so not allowlisted.
"Main",
"SSR",
"LTP",
"xHE-AAC",
// Missing or unrecognised profile must NOT fall through to the formula —
// that is how an unknown HE spelling kept the truncation bug.
"",
"SomethingNew",
])("does not apply the LC packet maths to profile %s", async (profile) => {
const { meta, calls } = await probe(
[{ kind: "exit", code: 0, stdout: aacStream(profile) }],
`/tmp/heaac-${profile}.m4a`,
);
expect(meta.durationSeconds).toBe(600);
// The second probe is not even attempted.
expect(calls).toHaveLength(1);
});
it.each(["LC", " lc "])("still refines AAC profile %s", async (profile) => {
const { meta } = await probe(
[
{ kind: "exit", code: 0, stdout: aacStream(profile) },
{
kind: "exit",
code: 0,
stdout: JSON.stringify({ streams: [{ nb_read_packets: "861" }], format: {} }),
},
],
`/tmp/aac-lc-${profile.trim()}.m4a`,
);
expect(meta.durationSeconds).toBeCloseTo((861 * 1024) / 44100, 5);
});
it("still refines a plain AAC-LC stream", async () => {
const { meta } = await probe(
[
{ kind: "exit", code: 0, stdout: aacStream("LC") },
{
kind: "exit",
code: 0,
stdout: JSON.stringify({ streams: [{ nb_read_packets: "861" }], format: {} }),
},
],
"/tmp/aac-lc-refined.m4a",
);
expect(meta.durationSeconds).toBeCloseTo((861 * 1024) / 44100, 5);
});
});
describe("final video frame timestamp probes", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("child_process");
});
it("normalizes absolute frame PTS by the selected video stream start", async () => {
const { spawn, calls } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 64,
height: 64,
duration: "3",
start_time: "5",
r_frame_rate: "1/1",
avg_frame_rate: "1/1",
},
],
format: { duration: "8" },
}),
},
{ kind: "exit", code: 0, stdout: "5.000000,\n6.000000\n7.000000\n" },
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractFinalVideoFrameTimestamp, extractMediaMetadata } = await import("./ffprobe.js");
const metadata = await extractMediaMetadata("/tmp/nonzero-start.mp4");
expect(metadata.videoStreamDurationSeconds).toBe(3);
expect(metadata.videoStreamStartSeconds).toBe(5);
await expect(extractFinalVideoFrameTimestamp("/tmp/nonzero-start.mp4", metadata)).resolves.toBe(
2,
);
const intervalIndex = calls[1]?.args.indexOf("-read_intervals") ?? -1;
expect(calls[1]?.args[intervalIndex + 1]).toBe("7%8");
});
it("falls back to a bounded-output full scan when a transport cannot interval-seek", async () => {
const { spawn, calls } = createSpawnSpy([
{ kind: "exit", code: 0, stdout: "" },
{ kind: "exit", code: 0, stdout: "-2.000000,\n-1.000000\n0.000000\n" },
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractFinalVideoFrameTimestamp } = await import("./ffprobe.js");
await expect(
extractFinalVideoFrameTimestamp("/tmp/unindexed-negative-base.ts", {
videoStreamDurationSeconds: 3,
videoStreamStartSeconds: -2,
}),
).resolves.toBe(2);
const intervalIndex = calls[0]?.args.indexOf("-read_intervals") ?? -1;
expect(calls[0]?.args[intervalIndex + 1]).toBe("0%1");
expect(calls[1]?.args).not.toContain("-read_intervals");
});
it("does not share a caller-cancellable probe across render consumers", async () => {
type KillableFakeProc = FakeProc & { kill: (signal?: NodeJS.Signals) => boolean };
const processes: KillableFakeProc[] = [];
const spawn = () => {
const proc = new EventEmitter() as KillableFakeProc;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn(() => {
process.nextTick(() => proc.emit("close", null, "SIGTERM"));
return true;
});
processes.push(proc);
process.nextTick(() => proc.emit("spawn"));
return proc;
};
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractFinalVideoFrameTimestamp } = await import("./ffprobe.js");
const metadata = { videoStreamDurationSeconds: 3, videoStreamStartSeconds: 0 };
const firstController = new AbortController();
const secondController = new AbortController();
const first = extractFinalVideoFrameTimestamp(
"/tmp/shared-source.mp4",
metadata,
firstController.signal,
);
const second = extractFinalVideoFrameTimestamp(
"/tmp/shared-source.mp4",
metadata,
secondController.signal,
);
expect(processes).toHaveLength(2);
firstController.abort();
await expect(first).rejects.toThrow(/ffprobe abort/);
expect(processes[0]?.kill).toHaveBeenCalledWith("SIGTERM");
expect(processes[1]?.kill).not.toHaveBeenCalled();
processes[1]?.stdout.emit("data", Buffer.from("2.000000\n"));
processes[1]?.emit("close", 0, null);
await expect(second).resolves.toBe(2);
expect(secondController.signal.aborted).toBe(false);
});
it("deduplicates the interval and fallback chain within one cancellation scope", async () => {
const { spawn, calls } = createSpawnSpy([
{ kind: "exit", code: 0, stdout: "" },
{ kind: "exit", code: 0, stdout: "-2.000000\n-1.000000\n0.000000\n" },
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractFinalVideoFrameTimestamp } = await import("./ffprobe.js");
const metadata = { videoStreamDurationSeconds: 3, videoStreamStartSeconds: -2 };
const signal = new AbortController().signal;
await expect(
Promise.all([
extractFinalVideoFrameTimestamp("/tmp/repeated-held-tail.ts", metadata, signal),
extractFinalVideoFrameTimestamp("/tmp/repeated-held-tail.ts", metadata, signal),
]),
).resolves.toEqual([2, 2]);
expect(calls).toHaveLength(2);
expect(calls[0]?.args).toContain("-read_intervals");
expect(calls[1]?.args).not.toContain("-read_intervals");
});
it("still deduplicates cancellation-independent probes", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "exit", code: 0, stdout: "2.000000\n" }]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractFinalVideoFrameTimestamp } = await import("./ffprobe.js");
const metadata = { videoStreamDurationSeconds: 3, videoStreamStartSeconds: 0 };
await Promise.all([
extractFinalVideoFrameTimestamp("/tmp/shared-source.mp4", metadata),
extractFinalVideoFrameTimestamp("/tmp/shared-source.mp4", metadata),
]);
expect(calls).toHaveLength(1);
});
});
describe("runFfprobe process and stream handling", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("child_process");
});
// Regression: `--` protects "-intro.mp4" but not a path of exactly "-",
// which ffprobe rewrites to fd: AFTER option parsing and then reads stdin.
// With stdin left as an unwritten pipe the probe hung for the full 30s
// deadline and failed with an empty diagnostic.
it("rejects a filePath of '-' immediately instead of hanging on stdin", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "exit", code: 0, stdout: "{}" }]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata } = await import("./ffprobe.js");
await expect(extractMediaMetadata("-")).rejects.toThrow(/stdin is not a supported input path/);
expect(calls).toHaveLength(0);
});
it("never leaves the child's stdin as a writable pipe", async () => {
const stdios: unknown[] = [];
const spawn = (_c: string, _a: readonly string[], opts?: { stdio?: unknown }) => {
stdios.push(opts?.stdio);
const proc = new EventEmitter() as FakeProc;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
process.nextTick(() => {
proc.stdout.emit(
"data",
Buffer.from(
JSON.stringify({
streams: [{ codec_type: "video", codec_name: "h264", width: 2, height: 2 }],
format: { duration: "1" },
}),
),
);
proc.emit("close", 0);
});
return proc;
};
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata } = await import("./ffprobe.js");
await extractMediaMetadata("/tmp/stdio-shape.mp4");
expect(stdios[0]).toEqual(["ignore", "pipe", "pipe"]);
});
// NOTE on the StringDecoder change: a per-chunk toString() corrupts a
// multi-byte character split across a pipe boundary into U+FFFD, but
// U+FFFD is valid JSON string content, so JSON.parse still succeeds and
// extractMediaMetadata's public surface returns nothing that exposes the
// mangled tag value. There is no assertion here that fails on the old
// implementation, so rather than ship a test that cannot fail, the
// corruption is stated in the commit and this covers the bound instead.
it("refuses to parse stdout that exceeds the size bound", async () => {
const huge = "x".repeat(8_000_001);
const { spawn } = createSpawnSpy([{ kind: "exit", code: 0, stdout: huge }]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata } = await import("./ffprobe.js");
await expect(extractMediaMetadata("/tmp/unbounded-output.mov")).rejects.toThrow(
/exceeded 8000000 characters/,
);
});
});