mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
## Problem Windows renders commonly fail with environment errors before any real work starts: - `Browser was not found at the configured executablePath (...chrome-headless-shell.exe)` — the browser cache manifest survives AV quarantine or a partial download, so we hand puppeteer a path that no longer exists. - `[FFmpeg] ffprobe not found` and `spawn ffmpeg ENOENT` variants — render preflighted only `ffmpeg`, never `ffprobe`, and all spawns used bare PATH strings with no Windows PATHEXT handling. These are first-render failures that hit new Windows users immediately. ## Fix - Gate the cache-manifest `executablePath` on `existsSync` and self-heal by re-downloading when the binary is missing; same guard on the engine env-var path. - New shared environment preflight (`packages/cli/src/browser/preflight.ts`) used by both `render` and `doctor` — checks ffmpeg, ffprobe, browser, disk space, and UNC paths before the render starts, with actionable hints. - Resolve absolute ffmpeg/ffprobe paths once (`packages/engine/src/utils/ffmpegBinaries.ts`) and pass them to every engine spawn instead of relying on PATH. - Map opaque Windows ffmpeg exit codes to actionable messages. ## Testing - New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary. - CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean. - Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
import { EventEmitter } from "events";
|
|
import { readFileSync } from "fs";
|
|
import { resolve } from "path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { extractMediaMetadata, extractPngMetadataFromBuffer } 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 };
|
|
|
|
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.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;
|
|
|
|
afterEach(() => {
|
|
vi.resetModules();
|
|
vi.doUnmock("child_process");
|
|
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
|
|
});
|
|
|
|
it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => {
|
|
process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
|
|
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" },
|
|
}),
|
|
},
|
|
]);
|
|
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(calls[0]?.command).toBe(resolve("/tools/ffprobe.exe"));
|
|
});
|
|
|
|
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
|
|
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
|
|
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(calls[0]?.command).toBe("ffprobe");
|
|
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);
|
|
});
|
|
|
|
// 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" }]);
|
|
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("extractAudioMetadata surfaces a ffprobe-missing error verbatim", async () => {
|
|
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
|
|
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(calls[0]?.command).toBe("ffprobe");
|
|
});
|
|
|
|
it("analyzeKeyframeIntervals surfaces a ffprobe-missing error verbatim", async () => {
|
|
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
|
|
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(calls[0]?.command).toBe("ffprobe");
|
|
});
|
|
|
|
it("ffprobe-missing error message includes install hint", async () => {
|
|
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
|
|
vi.resetModules();
|
|
vi.doMock("child_process", () => ({ spawn }));
|
|
|
|
const { extractAudioMetadata } = await import("./ffprobe.js");
|
|
|
|
await expect(extractAudioMetadata("/tmp/example.mp3")).rejects.toThrow(/install FFmpeg/i);
|
|
});
|
|
});
|