mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
* fix(studio-server): keep transparency in proxies of VP8/VP9 alpha webm A webm whose alpha lives in the VP8/VP9 side channel (alpha_mode=1, pix_fmt yuv420p) was proxied to opaque H.264, and the VP8 proxy path decoded it with ffmpeg's native decoder, which drops alpha. The alpha rule and the alpha-reading decoder now live once in @hyperframes/parsers/media-alpha, used by engine and studio-server. * build(parsers): raise the packed size budget for the media-alpha subpath parsers already sat at its packed budget on main; the shared alpha rule adds 676 B of JS and 803 B of declarations (190958 B packed in CI).
1272 lines
48 KiB
TypeScript
1272 lines
48 KiB
TypeScript
import { execFileSync, spawn as realSpawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
import { EventEmitter, once } from "node:events";
|
|
import {
|
|
existsSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
utimesSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join } from "node:path";
|
|
import { hdrToSdrToneMapFilter } from "@hyperframes/core";
|
|
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const FFMPEG_PATH = "/usr/bin/ffmpeg";
|
|
const realFfmpeg = findFfBinary("ffmpeg");
|
|
const realFfmpegFilters = realFfmpeg
|
|
? spawnSync(realFfmpeg, ["-hide_banner", "-filters"], { encoding: "utf8" }).stdout
|
|
: "";
|
|
const canToneMapForReal =
|
|
Boolean(findFfBinary("ffprobe")) && /\szscale\s/.test(realFfmpegFilters ?? "");
|
|
const realFfmpegEncoders = realFfmpeg
|
|
? spawnSync(realFfmpeg, ["-hide_banner", "-encoders"], { encoding: "utf8" }).stdout
|
|
: "";
|
|
const canEncodeVpxAlphaForReal =
|
|
Boolean(findFfBinary("ffprobe")) &&
|
|
/\slibvpx\s/.test(realFfmpegEncoders ?? "") &&
|
|
/\slibvpx-vp9\s/.test(realFfmpegEncoders ?? "");
|
|
|
|
function meanLuma(path: string, filters = "", inputArgs: string[] = []): number {
|
|
const stats = execFileSync(
|
|
realFfmpeg!,
|
|
[
|
|
"-v",
|
|
"error",
|
|
...inputArgs,
|
|
"-i",
|
|
path,
|
|
"-frames:v",
|
|
"1",
|
|
"-vf",
|
|
`${filters}signalstats,metadata=print:file=-`,
|
|
"-f",
|
|
"null",
|
|
"-",
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return Number(/YAVG=([\d.]+)/.exec(stats)?.[1]);
|
|
}
|
|
|
|
// Mirrors MAX_CONCURRENT_TRANSCODES in proxyTranscoder.ts (not exported —
|
|
// this test file and the module are authored together).
|
|
const MAX_CONCURRENT = 2;
|
|
const MAX_QUEUED = 8;
|
|
|
|
type FakeProc = EventEmitter & { stderr: EventEmitter; stdout: EventEmitter };
|
|
|
|
function createFakeProc(): FakeProc {
|
|
const proc = new EventEmitter() as FakeProc;
|
|
proc.stderr = new EventEmitter();
|
|
proc.stdout = new EventEmitter();
|
|
return proc;
|
|
}
|
|
|
|
type SpawnOptions = { windowsHide?: boolean; signal?: AbortSignal };
|
|
type SpawnCall = { command: string; args: string[]; options?: SpawnOptions; proc: FakeProc };
|
|
type SpawnImpl = (command: string, args: string[], options?: SpawnOptions) => FakeProc;
|
|
|
|
function createSpawnSpy(): { spawn: SpawnImpl; calls: SpawnCall[] } {
|
|
const calls: SpawnCall[] = [];
|
|
const spawn: SpawnImpl = (command, args, options) => {
|
|
const proc = createFakeProc();
|
|
calls.push({ command, args, options, proc });
|
|
return proc;
|
|
};
|
|
return { spawn, calls };
|
|
}
|
|
|
|
/** Mimics ffmpeg writing its output file before exiting 0. */
|
|
function succeed(call: SpawnCall, contents = "fake-h264-bytes"): void {
|
|
const outputPath = call.args.at(-1);
|
|
if (!outputPath) throw new Error("spawn call had no output path arg");
|
|
writeFileSync(outputPath, contents);
|
|
call.proc.emit("close", 0);
|
|
}
|
|
|
|
function fail(call: SpawnCall, code = 1, stderr = "boom"): void {
|
|
call.proc.stderr.emit("data", Buffer.from(stderr));
|
|
call.proc.emit("close", code);
|
|
}
|
|
|
|
async function flush(times = 6): Promise<void> {
|
|
for (let i = 0; i < times; i++) await Promise.resolve();
|
|
}
|
|
|
|
/** One more source than the queue can hold, to prove a batch never overflows it. */
|
|
function batchSources(projectDir: string): Array<{ sourcePath: string; variant: "h264" }> {
|
|
return Array.from({ length: MAX_CONCURRENT + MAX_QUEUED + 1 }, (_, i) => {
|
|
const sourcePath = join(projectDir, `batch-${i}.mov`);
|
|
writeFileSync(sourcePath, `source-${i}`);
|
|
return { sourcePath, variant: "h264" };
|
|
});
|
|
}
|
|
|
|
/** Answers every ffmpeg spawn until no new one starts. A finished copy reaches the
|
|
* next spawn through several promise hops, hence the deep flush. */
|
|
async function answerUntilIdle(
|
|
calls: SpawnCall[],
|
|
answer: (call: SpawnCall) => void,
|
|
): Promise<void> {
|
|
for (let done = 0; ; done = calls.length) {
|
|
await flush(50);
|
|
if (calls.length === done) return;
|
|
calls.slice(done).forEach(answer);
|
|
}
|
|
}
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
function tmpProject(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "hf-proxy-transcoder-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
vi.resetModules();
|
|
vi.doUnmock("node:child_process");
|
|
vi.doUnmock("@hyperframes/parsers/ff-binaries");
|
|
delete process.env.HYPERFRAMES_PROXY_MAX_CONCURRENCY;
|
|
delete process.env.HYPERFRAMES_PROXY_MAX_QUEUE;
|
|
});
|
|
|
|
async function loadModule(
|
|
spawn: SpawnImpl,
|
|
ffmpegPath: string | undefined,
|
|
isHdr = false,
|
|
hdrTransfer: string | null = isHdr ? "pq" : null,
|
|
codecName?: string,
|
|
): Promise<typeof import("./proxyTranscoder.js")> {
|
|
vi.resetModules();
|
|
vi.doMock("node:child_process", () => {
|
|
const mocked = { spawn };
|
|
return { ...mocked, default: mocked };
|
|
});
|
|
vi.doMock("@hyperframes/parsers/ff-binaries", () => ({
|
|
findFfBinary: () => ffmpegPath,
|
|
}));
|
|
vi.doMock("./mediaMetadata.js", () => ({
|
|
probeMediaMetadata: async () => ({
|
|
kind: "video",
|
|
color: { isHdr, hdrTransfer, codecName },
|
|
}),
|
|
probeFirstFrameColour: async () => ({}),
|
|
}));
|
|
return import("./proxyTranscoder.js");
|
|
}
|
|
|
|
describe("resolveProxy", () => {
|
|
it("bounds a caller wait without cancelling the shared transcode promise", async () => {
|
|
const { waitForProxy, ProxyWaitTimeoutError } = await loadModule(
|
|
() => createFakeProc(),
|
|
FFMPEG_PATH,
|
|
);
|
|
let finish!: (value: string) => void;
|
|
const shared = new Promise<string>((resolvePromise) => {
|
|
finish = resolvePromise;
|
|
});
|
|
|
|
await expect(waitForProxy(shared, 1)).rejects.toBeInstanceOf(ProxyWaitTimeoutError);
|
|
finish("eventual-proxy.mp4");
|
|
await expect(shared).resolves.toBe("eventual-proxy.mp4");
|
|
});
|
|
|
|
it("transcodes once on a cache miss and caches via temp+rename", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const expectedCachePath = getProxyCachePath(projectDir, sourcePath);
|
|
const resultPromise = resolveProxy(projectDir, sourcePath);
|
|
|
|
await flush();
|
|
expect(calls).toHaveLength(1);
|
|
const call = calls[0]!;
|
|
expect(call.args).toContain("-c:v");
|
|
expect(call.args).toContain("libx264");
|
|
expect(call.args).toContain("-crf");
|
|
expect(call.args).toContain("18");
|
|
expect(call.args).toContain("-preset");
|
|
expect(call.args).toContain("veryfast");
|
|
expect(call.args).toContain("-movflags");
|
|
expect(call.args).toContain("+faststart");
|
|
expect(call.args).toContain("-c:a");
|
|
expect(call.args).toContain("aac");
|
|
expect(call.args.some((a) => a.includes("scale="))).toBe(true);
|
|
expect(call.args).toContain("-pix_fmt");
|
|
expect(call.args).toContain("yuv420p");
|
|
expect(call.args).toContain("-colorspace");
|
|
expect(call.args).toContain("bt709");
|
|
expect(call.args).toContain("-color_primaries");
|
|
expect(call.args).toContain("-color_trc");
|
|
// temp-name-then-rename: the ffmpeg output target is not the final path.
|
|
const outputArg = call.args.at(-1)!;
|
|
expect(outputArg).not.toBe(expectedCachePath);
|
|
expect(existsSync(expectedCachePath)).toBe(false);
|
|
|
|
succeed(call);
|
|
const result = await resultPromise;
|
|
|
|
expect(result).toBe(expectedCachePath);
|
|
expect(existsSync(expectedCachePath)).toBe(true);
|
|
// No leftover temp file next to the final cache entry.
|
|
const cacheDirEntries = readdirSync(join(projectDir, ".transcode-cache"));
|
|
expect(cacheDirEntries).toEqual([expectedCachePath.split("/").at(-1)]);
|
|
});
|
|
|
|
it("names the boxed preview copy resolveProxy writes when given the same box", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "clip.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
const box = { width: 1024, height: 724 };
|
|
|
|
const boxedPath = getProxyCachePath(projectDir, sourcePath, "h264", box);
|
|
const resultPromise = resolveProxy(projectDir, sourcePath, "h264", box);
|
|
await flush();
|
|
succeed(calls[0]!);
|
|
|
|
expect(await resultPromise).toBe(boxedPath);
|
|
expect(boxedPath).not.toBe(getProxyCachePath(projectDir, sourcePath, "h264"));
|
|
});
|
|
|
|
it("uses Chromium-compatible VP8 alpha args and a distinct WebM cache path", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "alpha.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const h264Path = getProxyCachePath(projectDir, sourcePath, "h264");
|
|
const vp8Path = getProxyCachePath(projectDir, sourcePath, "vp8");
|
|
const resultPromise = resolveProxy(projectDir, sourcePath, "vp8");
|
|
await flush();
|
|
|
|
expect(vp8Path).not.toBe(h264Path);
|
|
expect(vp8Path).toMatch(/\.webm$/);
|
|
expect(h264Path).toMatch(/\.mp4$/);
|
|
const args = calls[0]!.args;
|
|
expect(args).toContain("libvpx");
|
|
expect(args).not.toContain("libvpx-vp9");
|
|
expect(args).toContain("yuva420p");
|
|
expect(args).toContain("libopus");
|
|
expect(args[args.indexOf("-b:v") + 1]).toBe("0");
|
|
expect(args[args.indexOf("-crf") + 1]).toBe("23");
|
|
expect(args[args.indexOf("-deadline") + 1]).toBe("good");
|
|
expect(args[args.indexOf("-auto-alt-ref") + 1]).toBe("0");
|
|
expect(args[args.indexOf("-metadata:s:v:0") + 1]).toBe("alpha_mode=1");
|
|
expect(args[args.indexOf("-ac") + 1]).toBe("2");
|
|
expect(args).not.toContain("-row-mt");
|
|
expect(args).toContain("-cpu-used");
|
|
expect(args).not.toContain("-movflags");
|
|
expect(args).not.toContain("+faststart");
|
|
expect(args[args.indexOf("-vf") + 1]).toContain("format=yuva420p");
|
|
|
|
succeed(calls[0]!, "fake-vp8-bytes");
|
|
await expect(resultPromise).resolves.toBe(vp8Path);
|
|
});
|
|
|
|
it("decodes a VP9 alpha source with libvpx-vp9 so the VP8 proxy keeps its alpha", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, false, null, "vp9");
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "cutout.webm");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const vp8 = resolveProxy(projectDir, sourcePath, "vp8");
|
|
await flush();
|
|
const vp8Args = calls[0]!.args;
|
|
expect(vp8Args.slice(vp8Args.indexOf("-i") - 2, vp8Args.indexOf("-i"))).toEqual([
|
|
"-c:v",
|
|
"libvpx-vp9",
|
|
]);
|
|
succeed(calls[0]!, "fake-vp8-bytes");
|
|
await vp8;
|
|
|
|
const h264 = resolveProxy(projectDir, sourcePath, "h264");
|
|
await flush();
|
|
expect(calls[1]!.args.slice(0, calls[1]!.args.indexOf("-i"))).not.toContain("-c:v");
|
|
succeed(calls[1]!);
|
|
await h264;
|
|
});
|
|
|
|
it.skipIf(!canEncodeVpxAlphaForReal)(
|
|
"keeps a real VP9 alpha webm transparent through probe, variant and transcode",
|
|
async () => {
|
|
vi.resetModules();
|
|
vi.doUnmock("./mediaMetadata.js");
|
|
const { resolveProxy } = await import("./proxyTranscoder.js");
|
|
const { probeAssetCodec, proxyVariantFor } = await import("./mediaCodecMap.js");
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "cutout.webm");
|
|
// Left half fully transparent, right half opaque: mean alpha 127.5.
|
|
execFileSync(realFfmpeg!, [
|
|
...["-v", "error", "-f", "lavfi", "-i"],
|
|
"testsrc2=s=64x64:d=0.2,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='if(lt(X,32),0,255)'",
|
|
...["-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-auto-alt-ref", "0"],
|
|
...["-metadata:s:v:0", "alpha_mode=1", sourcePath],
|
|
]);
|
|
|
|
const facts = await probeAssetCodec(sourcePath);
|
|
expect(facts).toMatchObject({ codecName: "vp9", hasAlpha: true });
|
|
const proxyPath = await resolveProxy(projectDir, sourcePath, proxyVariantFor(facts!));
|
|
expect(proxyPath).toMatch(/\.webm$/);
|
|
const meanAlpha = meanLuma(proxyPath, "alphaextract,", ["-c:v", "libvpx"]);
|
|
expect(Math.abs(meanAlpha - 127.5)).toBeLessThan(8);
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
it("returns without spawning on a cache hit", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const cachePath = getProxyCachePath(projectDir, sourcePath);
|
|
const { mkdirSync } = await import("node:fs");
|
|
mkdirSync(join(projectDir, ".transcode-cache"), { recursive: true });
|
|
writeFileSync(cachePath, "already-cached");
|
|
|
|
const result = await resolveProxy(projectDir, sourcePath);
|
|
expect(result).toBe(cachePath);
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("tone-maps HDR input before emitting browser-safe BT.709", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, true);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "hdr.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const result = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
|
|
expect(calls[0]!.args).toEqual(["-hide_banner", "-filters"]);
|
|
expect(calls[0]!.options?.windowsHide).toBe(true);
|
|
calls[0]!.proc.stdout.emit(
|
|
"data",
|
|
Buffer.from(" ..C zscale V->V zimg scale\n T.C tonemap V->V tone map\n"),
|
|
);
|
|
calls[0]!.proc.emit("close", 0);
|
|
await flush();
|
|
|
|
const filterIndex = calls[1]!.args.indexOf("-vf");
|
|
const filter = calls[1]!.args[filterIndex + 1];
|
|
expect(filter).toContain("tonemap=");
|
|
expect(filter).toContain("bt709");
|
|
expect(filter).toContain("out_color_matrix=bt709");
|
|
|
|
succeed(calls[1]!);
|
|
await result;
|
|
});
|
|
|
|
it("preserves alpha on HDR-tagged VP8 proxies by bypassing the opaque tonemap chain", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, true);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "hdr-alpha.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const result = resolveProxy(projectDir, sourcePath, "vp8");
|
|
await flush();
|
|
|
|
expect(calls).toHaveLength(1);
|
|
const filter = calls[0]!.args[calls[0]!.args.indexOf("-vf") + 1];
|
|
expect(filter).toContain("format=yuva420p");
|
|
expect(filter).not.toContain("tonemap=");
|
|
succeed(calls[0]!, "fake-vp8-alpha-bytes");
|
|
await result;
|
|
});
|
|
|
|
it("rejects HDR proxying with a typed actionable error when ffmpeg lacks zscale", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, FfmpegMissingFilterError } = await loadModule(spawn, FFMPEG_PATH, true);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "hdr.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const result = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
|
|
expect(calls[0]!.args).toEqual(["-hide_banner", "-filters"]);
|
|
calls[0]!.proc.stdout.emit("data", Buffer.from(" T.C tonemap V->V tone map\n"));
|
|
calls[0]!.proc.emit("close", 0);
|
|
|
|
await expect(result).rejects.toBeInstanceOf(FfmpegMissingFilterError);
|
|
await expect(result).rejects.toThrow(/zscale.*libzimg/i);
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it("does not tone-map BT.2020 footage without a PQ or HLG transfer, as renders do", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, true, "unknown");
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "bt2020-sdr.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const result = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]!.args).toContain("-vf");
|
|
expect(calls[0]!.args[calls[0]!.args.indexOf("-vf") + 1]).not.toContain("tonemap=");
|
|
succeed(calls[0]!);
|
|
await result;
|
|
});
|
|
|
|
it.skipIf(!canToneMapForReal)(
|
|
"tone-maps real HLG footage that tags only its transfer, as renders do",
|
|
async () => {
|
|
vi.resetModules();
|
|
vi.doUnmock("./mediaMetadata.js");
|
|
const { resolveProxy } = await import("./proxyTranscoder.js");
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "hlg-transfer-only.mp4");
|
|
// Primaries and matrix "unspecified" (2), transfer HLG (18).
|
|
const hlgTransferOnly =
|
|
"h264_metadata=colour_primaries=2:transfer_characteristics=18:matrix_coefficients=2";
|
|
execFileSync(realFfmpeg!, [
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc2=s=64x36:d=0.2",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-bsf:v",
|
|
hlgTransferOnly,
|
|
sourcePath,
|
|
]);
|
|
|
|
const proxyPath = await resolveProxy(projectDir, sourcePath);
|
|
// Reference: the render tone map's RGB, converted with BT.709. Mean luma is ~53 there;
|
|
// skipping the tone map gives ~60, a BT.601 conversion ~56.
|
|
const reference = meanLuma(
|
|
sourcePath,
|
|
`${hdrToSdrToneMapFilter({ colorTransfer: "arib-std-b67" }, { colorTransfer: "arib-std-b67" })},format=gbrp,scale=in_range=pc:out_color_matrix=bt709:out_range=tv,format=yuv420p,`,
|
|
);
|
|
expect(Math.abs(meanLuma(proxyPath) - reference)).toBeLessThan(1);
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
it.skipIf(!canToneMapForReal)(
|
|
"tone-maps each frame of a real mixed HLG and PQ stream with its own transfer",
|
|
async () => {
|
|
vi.resetModules();
|
|
vi.doUnmock("./mediaMetadata.js");
|
|
const { resolveProxy } = await import("./proxyTranscoder.js");
|
|
const projectDir = tmpProject();
|
|
const segment = (transfer: number) =>
|
|
execFileSync(realFfmpeg!, [
|
|
...["-v", "error", "-f", "lavfi", "-i", "testsrc2=s=320x180:r=25:d=0.2"],
|
|
// No B-frames: genpts stamps a raw stream in decode order, so reordered frames get wrong times.
|
|
...["-c:v", "libx264", "-bf", "0", "-pix_fmt", "yuv420p", "-bsf:v"],
|
|
`h264_metadata=colour_primaries=9:transfer_characteristics=${transfer}:matrix_coefficients=9`,
|
|
...["-f", "h264", "-"],
|
|
]);
|
|
const mux = (name: string, stream: Buffer) => {
|
|
const raw = join(projectDir, `${name}.h264`);
|
|
const path = join(projectDir, `${name}.mp4`);
|
|
writeFileSync(raw, stream);
|
|
execFileSync(realFfmpeg!, [
|
|
...["-v", "error", "-fflags", "+genpts", "-r", "25", "-f", "h264", "-i", raw],
|
|
...["-c", "copy", path],
|
|
]);
|
|
return path;
|
|
};
|
|
const pq = segment(16);
|
|
const mixedProxy = await resolveProxy(
|
|
projectDir,
|
|
mux("hlg-then-pq", Buffer.concat([segment(18), pq])),
|
|
);
|
|
const pqProxy = await resolveProxy(projectDir, mux("pq-only", pq));
|
|
|
|
// Frame 5 is the first PQ frame: tone-mapped as PQ it lands near 42 dB, read as HLG near 30.
|
|
const psnrAt = (n: number) => {
|
|
const stats = spawnSync(
|
|
realFfmpeg!,
|
|
[
|
|
...["-i", mixedProxy, "-i", pqProxy, "-lavfi"],
|
|
`[0:v]select=eq(n\\,${n}),setpts=PTS-STARTPTS[a];[1:v]select=eq(n\\,0),setpts=PTS-STARTPTS[b];[a][b]psnr`,
|
|
...["-frames:v", "1", "-f", "null", "-"],
|
|
],
|
|
{ encoding: "utf8" },
|
|
).stderr;
|
|
return Number(/average:([\d.]+)/.exec(stats)?.[1] ?? 0);
|
|
};
|
|
expect(psnrAt(5)).toBeGreaterThan(36);
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
it.skipIf(!realFfmpeg || !findFfBinary("ffprobe"))(
|
|
"makes a preview copy that fills the shown box, never larger than the source",
|
|
async () => {
|
|
vi.resetModules();
|
|
vi.doUnmock("./mediaMetadata.js");
|
|
const { resolveProxy } = await import("./proxyTranscoder.js");
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "portrait.mp4");
|
|
execFileSync(realFfmpeg!, [
|
|
...["-v", "error", "-f", "lavfi", "-i", "testsrc2=s=1080x1920:d=0.1"],
|
|
...["-c:v", "libx264", "-pix_fmt", "yuv420p", sourcePath],
|
|
]);
|
|
const size = (path: string) =>
|
|
execFileSync(
|
|
findFfBinary("ffprobe")!,
|
|
[
|
|
...["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height"],
|
|
...["-of", "csv=p=0", "--", path],
|
|
],
|
|
{ encoding: "utf8" },
|
|
).trim();
|
|
|
|
// The width side binds: 512/1080 > 724/1920, so the height follows the source's shape.
|
|
const boxed = await resolveProxy(projectDir, sourcePath, "h264", { width: 512, height: 724 });
|
|
expect(size(boxed)).toBe("512,912");
|
|
const roomy = await resolveProxy(projectDir, sourcePath, "h264", {
|
|
width: 2048,
|
|
height: 2048,
|
|
});
|
|
expect(size(roomy)).toBe("1080,1920");
|
|
const full = await resolveProxy(projectDir, sourcePath);
|
|
expect(size(full)).toBe("1080,1920");
|
|
expect(new Set([boxed, roomy, full]).size).toBe(3);
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
it("dedupes two concurrent same-key calls to one spawn", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const p1 = resolveProxy(projectDir, sourcePath);
|
|
const p2 = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
expect(calls).toHaveLength(1);
|
|
|
|
succeed(calls[0]!);
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
expect(r1).toBe(r2);
|
|
});
|
|
|
|
it("respects the global concurrency bound across distinct keys", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePaths = Array.from({ length: 5 }, (_, i) => {
|
|
const p = join(projectDir, `video-${i}.mov`);
|
|
writeFileSync(p, `source-bytes-${i}`);
|
|
return p;
|
|
});
|
|
|
|
const results = sourcePaths.map((p) => resolveProxy(projectDir, p));
|
|
await flush();
|
|
expect(calls).toHaveLength(MAX_CONCURRENT);
|
|
|
|
succeed(calls[0]!);
|
|
succeed(calls[1]!);
|
|
await flush();
|
|
expect(calls).toHaveLength(4);
|
|
|
|
succeed(calls[2]!);
|
|
succeed(calls[3]!);
|
|
await flush();
|
|
expect(calls).toHaveLength(5);
|
|
|
|
succeed(calls[4]!);
|
|
const resolved = await Promise.all(results);
|
|
expect(new Set(resolved).size).toBe(5);
|
|
// At no point did more than MAX_CONCURRENT spawns run unresolved at once.
|
|
expect(calls.length).toBeLessThanOrEqual(sourcePaths.length);
|
|
});
|
|
|
|
it("rejects excess queued work with a typed capacity error", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, ProxyCapacityError } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePaths = Array.from({ length: MAX_CONCURRENT + MAX_QUEUED + 1 }, (_, i) => {
|
|
const path = join(projectDir, `queued-${i}.mov`);
|
|
writeFileSync(path, `source-${i}`);
|
|
return path;
|
|
});
|
|
|
|
const accepted = sourcePaths.slice(0, -1).map((path) => resolveProxy(projectDir, path));
|
|
await expect(resolveProxy(projectDir, sourcePaths.at(-1)!)).rejects.toBeInstanceOf(
|
|
ProxyCapacityError,
|
|
);
|
|
await flush();
|
|
expect(calls).toHaveLength(MAX_CONCURRENT);
|
|
|
|
for (let index = 0; index < accepted.length; index += MAX_CONCURRENT) {
|
|
calls.slice(index, index + MAX_CONCURRENT).forEach((call) => succeed(call));
|
|
await flush();
|
|
}
|
|
await Promise.all(accepted);
|
|
|
|
// A full queue is not remembered: once there is room, the same clip transcodes.
|
|
const retry = resolveProxy(projectDir, sourcePaths.at(-1)!);
|
|
await flush();
|
|
expect(calls).toHaveLength(accepted.length + 1);
|
|
succeed(calls.at(-1)!);
|
|
await expect(retry).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("resolves a whole project's batch past the queue cap by waiting for room, not refusing", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxies } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sources = batchSources(projectDir);
|
|
|
|
let settled = 0;
|
|
const batch = resolveProxies(projectDir, sources, () => settled++);
|
|
await answerUntilIdle(calls, (call) => succeed(call));
|
|
|
|
const results = await batch;
|
|
expect(results.map((result) => result.status)).toEqual(sources.map(() => "fulfilled"));
|
|
expect(settled).toBe(sources.length);
|
|
});
|
|
|
|
it("settles each request of a batch on its own when one of them fails", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxies } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sources = batchSources(projectDir);
|
|
const failing = (call: SpawnCall) => call.args.some((arg) => arg.endsWith("batch-3.mov"));
|
|
|
|
const batch = resolveProxies(projectDir, sources);
|
|
await answerUntilIdle(calls, (call) => (failing(call) ? fail(call) : succeed(call)));
|
|
|
|
const statuses = (await batch).map((result) => result.status);
|
|
expect(statuses.filter((status) => status === "fulfilled")).toHaveLength(sources.length - 1);
|
|
expect(statuses[3]).toBe("rejected");
|
|
});
|
|
|
|
describe("queue order and callers that leave", () => {
|
|
function clipOf(call: SpawnCall): string {
|
|
return basename(call.args[call.args.indexOf("-i") + 1]!, ".mov");
|
|
}
|
|
|
|
function clips(projectDir: string, names: string[]): Record<string, string> {
|
|
return Object.fromEntries(
|
|
names.map((name) => {
|
|
const path = join(projectDir, `${name}.mov`);
|
|
writeFileSync(path, name);
|
|
return [name, path];
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function askThenLeave(ask: (signal: AbortSignal) => Promise<string>): Promise<void> {
|
|
const leaving = new AbortController();
|
|
const left = ask(leaving.signal);
|
|
await flush();
|
|
leaving.abort();
|
|
await expect(left).rejects.toBe(leaving.signal.reason);
|
|
}
|
|
|
|
async function oneSlot(queue = 8, slots = 1) {
|
|
process.env.HYPERFRAMES_PROXY_MAX_CONCURRENCY = String(slots);
|
|
process.env.HYPERFRAMES_PROXY_MAX_QUEUE = String(queue);
|
|
const spy = createSpawnSpy();
|
|
return { ...spy, ...(await loadModule(spy.spawn, FFMPEG_PATH)), projectDir: tmpProject() };
|
|
}
|
|
|
|
it("moves another project's activity mark when its ask is refused, and copies it once there is room", async () => {
|
|
const { calls, resolveProxy, ProxyCapacityError, proxyActivityMark, projectDir } =
|
|
await oneSlot(0);
|
|
const busyProject = tmpProject();
|
|
const running = resolveProxy(busyProject, clips(busyProject, ["running"]).running!);
|
|
const target = clips(projectDir, ["target"]).target!;
|
|
const before = proxyActivityMark(projectDir);
|
|
|
|
await expect(resolveProxy(projectDir, target)).rejects.toBeInstanceOf(ProxyCapacityError);
|
|
expect(proxyActivityMark(projectDir)).not.toBe(before);
|
|
|
|
succeed(calls[0]!);
|
|
await running;
|
|
const retry = resolveProxy(projectDir, target);
|
|
await flush();
|
|
expect(calls.map(clipOf)).toEqual(["running", "target"]);
|
|
succeed(calls[1]!);
|
|
await retry;
|
|
});
|
|
|
|
it("starts a priority copy before queued thumbnails, even when the queue is full", async () => {
|
|
const { calls, resolveProxy, ProxyCapacityError, projectDir } = await oneSlot(1);
|
|
const clip = clips(projectDir, ["running", "thumb", "late", "preview"]);
|
|
|
|
const running = resolveProxy(projectDir, clip.running!);
|
|
const thumb = resolveProxy(projectDir, clip.thumb!);
|
|
await expect(resolveProxy(projectDir, clip.late!)).rejects.toBeInstanceOf(ProxyCapacityError);
|
|
const preview = resolveProxy(projectDir, clip.preview!, "h264", undefined, {
|
|
priority: true,
|
|
});
|
|
await flush();
|
|
|
|
succeed(calls[0]!);
|
|
await running;
|
|
await flush();
|
|
expect(clipOf(calls[1]!)).toBe("preview");
|
|
succeed(calls[1]!);
|
|
await preview;
|
|
await flush();
|
|
expect(clipOf(calls[2]!)).toBe("thumb");
|
|
succeed(calls[2]!);
|
|
await thumb;
|
|
});
|
|
|
|
it("moves an already queued copy forward when the preview asks for it", async () => {
|
|
const { calls, resolveProxy, projectDir } = await oneSlot();
|
|
const clip = clips(projectDir, ["running", "first", "second"]);
|
|
|
|
const asks = [
|
|
resolveProxy(projectDir, clip.running!),
|
|
resolveProxy(projectDir, clip.first!),
|
|
resolveProxy(projectDir, clip.second!),
|
|
resolveProxy(projectDir, clip.second!, "h264", undefined, { priority: true }),
|
|
];
|
|
await flush();
|
|
succeed(calls[0]!);
|
|
await flush(12);
|
|
|
|
expect(clipOf(calls[1]!)).toBe("second");
|
|
succeed(calls[1]!);
|
|
await flush(12);
|
|
succeed(calls[2]!);
|
|
await Promise.all(asks);
|
|
expect(calls).toHaveLength(3);
|
|
});
|
|
|
|
it("drops a queued copy once its only caller leaves, without remembering a failure", async () => {
|
|
const { calls, resolveProxy, projectDir } = await oneSlot();
|
|
const clip = clips(projectDir, ["running", "thumb"]);
|
|
const scrolledOff = new AbortController();
|
|
|
|
const running = resolveProxy(projectDir, clip.running!);
|
|
const thumb = resolveProxy(projectDir, clip.thumb!, "h264", undefined, {
|
|
signal: scrolledOff.signal,
|
|
});
|
|
await flush();
|
|
scrolledOff.abort();
|
|
await expect(thumb).rejects.toBe(scrolledOff.signal.reason);
|
|
|
|
succeed(calls[0]!);
|
|
await running;
|
|
await flush(12);
|
|
expect(calls).toHaveLength(1);
|
|
|
|
const again = resolveProxy(projectDir, clip.thumb!);
|
|
await flush();
|
|
expect(calls).toHaveLength(2);
|
|
succeed(calls[1]!);
|
|
await expect(again).resolves.toBeTruthy();
|
|
});
|
|
|
|
it.each([
|
|
["another caller with a signal", true],
|
|
["a caller without a signal", false],
|
|
])("keeps a shared queued copy while %s still waits", async (_label, withSignal) => {
|
|
const { calls, resolveProxy, projectDir } = await oneSlot();
|
|
const clip = clips(projectDir, ["running", "shared"]);
|
|
|
|
const running = resolveProxy(projectDir, clip.running!);
|
|
const staying = resolveProxy(
|
|
projectDir,
|
|
clip.shared!,
|
|
"h264",
|
|
undefined,
|
|
withSignal ? { signal: new AbortController().signal } : {},
|
|
);
|
|
await askThenLeave((signal) =>
|
|
resolveProxy(projectDir, clip.shared!, "h264", undefined, { signal }),
|
|
);
|
|
|
|
succeed(calls[0]!);
|
|
await running;
|
|
await flush(12);
|
|
expect(clipOf(calls[1]!)).toBe("shared");
|
|
succeed(calls[1]!);
|
|
await expect(staying).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("makes the copy when the preview asks in the same moment the last thumbnail leaves", async () => {
|
|
const { calls, resolveProxy, projectDir } = await oneSlot();
|
|
const clip = clips(projectDir, ["running", "handoff"]);
|
|
const leaving = new AbortController();
|
|
|
|
const running = resolveProxy(projectDir, clip.running!);
|
|
const thumb = resolveProxy(projectDir, clip.handoff!, "h264", undefined, {
|
|
signal: leaving.signal,
|
|
});
|
|
await flush();
|
|
leaving.abort();
|
|
const preview = resolveProxy(projectDir, clip.handoff!, "h264", undefined, {
|
|
priority: true,
|
|
});
|
|
await expect(thumb).rejects.toBe(leaving.signal.reason);
|
|
|
|
succeed(calls[0]!);
|
|
await running;
|
|
await flush(12);
|
|
expect(clipOf(calls[1]!)).toBe("handoff");
|
|
succeed(calls[1]!);
|
|
await expect(preview).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("keeps one copy per clip when a dropped copy settles after its replacement started", async () => {
|
|
const { calls, resolveProxy, projectDir } = await oneSlot(8, 2);
|
|
const clip = clips(projectDir, ["first", "second", "handoff"]);
|
|
const leaving = new AbortController();
|
|
|
|
const running = [
|
|
resolveProxy(projectDir, clip.first!),
|
|
resolveProxy(projectDir, clip.second!),
|
|
];
|
|
const thumb = resolveProxy(projectDir, clip.handoff!, "h264", undefined, {
|
|
signal: leaving.signal,
|
|
});
|
|
await flush();
|
|
leaving.abort();
|
|
const preview = resolveProxy(projectDir, clip.handoff!);
|
|
await expect(thumb).rejects.toBe(leaving.signal.reason);
|
|
await flush(12);
|
|
const later = resolveProxy(projectDir, clip.handoff!);
|
|
|
|
succeed(calls[0]!);
|
|
succeed(calls[1]!);
|
|
await Promise.all(running);
|
|
await flush(12);
|
|
expect(calls.map(clipOf).filter((name) => name === "handoff")).toHaveLength(1);
|
|
succeed(calls[2]!);
|
|
await expect(Promise.all([preview, later])).resolves.toHaveLength(2);
|
|
});
|
|
|
|
it("accepts a priority ask in the same moment a normal ask for that clip was refused", async () => {
|
|
const { calls, resolveProxy, ProxyCapacityError, projectDir } = await oneSlot(1);
|
|
const clip = clips(projectDir, ["running", "thumb", "late"]);
|
|
|
|
const running = resolveProxy(projectDir, clip.running!);
|
|
const thumb = resolveProxy(projectDir, clip.thumb!);
|
|
const refused = resolveProxy(projectDir, clip.late!);
|
|
const preview = resolveProxy(projectDir, clip.late!, "h264", undefined, { priority: true });
|
|
await expect(refused).rejects.toBeInstanceOf(ProxyCapacityError);
|
|
|
|
succeed(calls[0]!);
|
|
await running;
|
|
await flush(12);
|
|
expect(clipOf(calls[1]!)).toBe("late");
|
|
succeed(calls[1]!);
|
|
await expect(preview).resolves.toBeTruthy();
|
|
await flush(12);
|
|
succeed(calls[2]!);
|
|
await thumb;
|
|
});
|
|
|
|
it("stops a started copy once its last caller leaves, and starts afresh on the next ask", async () => {
|
|
const { calls, resolveProxy, projectDir } = await oneSlot();
|
|
const clip = clips(projectDir, ["running"]);
|
|
|
|
await askThenLeave((signal) =>
|
|
resolveProxy(projectDir, clip.running!, "h264", undefined, { signal }),
|
|
);
|
|
|
|
expect(calls[0]!.options?.signal?.aborted).toBe(true);
|
|
calls[0]!.proc.emit("close", null, "SIGKILL");
|
|
await flush(12);
|
|
const again = resolveProxy(projectDir, clip.running!);
|
|
await flush();
|
|
expect(calls).toHaveLength(2);
|
|
succeed(calls[1]!);
|
|
await expect(again).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("keeps a started copy while a caller without a signal still waits", async () => {
|
|
const { calls, resolveProxy, projectDir } = await oneSlot();
|
|
const clip = clips(projectDir, ["running"]);
|
|
const kept = resolveProxy(projectDir, clip.running!);
|
|
|
|
await askThenLeave((signal) =>
|
|
resolveProxy(projectDir, clip.running!, "h264", undefined, { signal }),
|
|
);
|
|
|
|
expect(calls[0]!.options?.signal?.aborted).not.toBe(true);
|
|
succeed(calls[0]!);
|
|
await expect(kept).resolves.toBeTruthy();
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"kills the running ffmpeg when its last caller leaves",
|
|
async () => {
|
|
let child: ChildProcess | undefined;
|
|
const neverFinishes = (_command: string, _args: string[], options?: SpawnOptions) => {
|
|
child = realSpawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], options);
|
|
return child as unknown as FakeProc;
|
|
};
|
|
const { resolveProxy } = await loadModule(neverFinishes, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const clip = clips(projectDir, ["running"]);
|
|
|
|
await askThenLeave((signal) =>
|
|
resolveProxy(projectDir, clip.running!, "h264", undefined, { signal }),
|
|
);
|
|
|
|
const [, exitSignal] = child!.exitCode === null ? await once(child!, "exit") : [];
|
|
expect(exitSignal).toBe("SIGKILL");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("honors bounded concurrency and queue environment overrides", async () => {
|
|
process.env.HYPERFRAMES_PROXY_MAX_CONCURRENCY = "1";
|
|
process.env.HYPERFRAMES_PROXY_MAX_QUEUE = "0";
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, ProxyCapacityError } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const firstPath = join(projectDir, "first.mov");
|
|
const secondPath = join(projectDir, "second.mov");
|
|
writeFileSync(firstPath, "first");
|
|
writeFileSync(secondPath, "second");
|
|
|
|
const first = resolveProxy(projectDir, firstPath);
|
|
await flush();
|
|
expect(calls).toHaveLength(1);
|
|
await expect(resolveProxy(projectDir, secondPath)).rejects.toBeInstanceOf(ProxyCapacityError);
|
|
|
|
succeed(calls[0]!);
|
|
await expect(first).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("produces a new cache key when the source mtime changes", async () => {
|
|
const { resolveProxy: _unused, getProxyCachePath } = await loadModule(
|
|
() => createFakeProc(),
|
|
FFMPEG_PATH,
|
|
);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
const past = new Date(Date.now() - 60_000);
|
|
utimesSync(sourcePath, past, past);
|
|
const keyBefore = getProxyCachePath(projectDir, sourcePath);
|
|
|
|
const future = new Date(Date.now() + 60_000);
|
|
utimesSync(sourcePath, future, future);
|
|
const keyAfter = getProxyCachePath(projectDir, sourcePath);
|
|
|
|
expect(keyAfter).not.toBe(keyBefore);
|
|
void _unused;
|
|
});
|
|
|
|
it("produces a new cache key when size changes but mtime is pinned the same", async () => {
|
|
const { getProxyCachePath } = await loadModule(() => createFakeProc(), FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
const pinned = new Date("2026-01-01T00:00:00Z");
|
|
|
|
writeFileSync(sourcePath, "short");
|
|
utimesSync(sourcePath, pinned, pinned);
|
|
const keyBefore = getProxyCachePath(projectDir, sourcePath);
|
|
|
|
writeFileSync(sourcePath, "a much longer replacement payload");
|
|
utimesSync(sourcePath, pinned, pinned);
|
|
const keyAfter = getProxyCachePath(projectDir, sourcePath);
|
|
|
|
expect(keyAfter).not.toBe(keyBefore);
|
|
});
|
|
|
|
it("surfaces a typed error on ffmpeg failure and leaves no cache file", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, getProxyCachePath, ProxyTranscodeError } = await loadModule(
|
|
spawn,
|
|
FFMPEG_PATH,
|
|
);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
const cachePath = getProxyCachePath(projectDir, sourcePath);
|
|
|
|
const resultPromise = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
expect(calls).toHaveLength(1);
|
|
fail(
|
|
calls[0]!,
|
|
1,
|
|
[
|
|
"Input #0, mov, from 'video.mov': unsupported codec",
|
|
"[hevc] Error while decoding stream #0:0: Invalid data found when processing input",
|
|
"Error submitting a packet to the muxer: No space left on device.",
|
|
"Conversion failed!",
|
|
].join("\n"),
|
|
);
|
|
|
|
await expect(resultPromise).rejects.toBeInstanceOf(ProxyTranscodeError);
|
|
await expect(resultPromise).rejects.toMatchObject({
|
|
message:
|
|
"ffmpeg exited with code 1: Error submitting a packet to the muxer: No space left on device",
|
|
exitCode: 1,
|
|
stderrTail: expect.stringContaining("unsupported codec"),
|
|
});
|
|
|
|
expect(existsSync(cachePath)).toBe(false);
|
|
const cacheDir = join(projectDir, ".transcode-cache");
|
|
const leftover = existsSync(cacheDir) ? readdirSync(cacheDir) : [];
|
|
expect(leftover).toEqual([]);
|
|
});
|
|
|
|
it("remembers a failure per cache key: a second call rethrows without respawning ffmpeg", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, ProxyTranscodeError, clearFailedTranscodesForTest } = await loadModule(
|
|
spawn,
|
|
FFMPEG_PATH,
|
|
);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const first = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
expect(calls).toHaveLength(1);
|
|
fail(calls[0]!, 1, "ffmpeg: unsupported codec");
|
|
await expect(first).rejects.toBeInstanceOf(ProxyTranscodeError);
|
|
|
|
// Same key, remembered failure: rethrown instantly, no second spawn.
|
|
await expect(resolveProxy(projectDir, sourcePath)).rejects.toMatchObject({
|
|
stderrTail: expect.stringContaining("unsupported codec"),
|
|
});
|
|
expect(calls).toHaveLength(1);
|
|
|
|
// The exported clear hook forgets the failure and allows a retry.
|
|
clearFailedTranscodesForTest();
|
|
const retry = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[1]!.options?.windowsHide).toBe(true);
|
|
succeed(calls[1]!);
|
|
await expect(retry).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("expires remembered failures so transient environment errors can recover", async () => {
|
|
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, ProxyTranscodeError } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const first = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
fail(calls[0]!, 137, "transient OOM");
|
|
await expect(first).rejects.toBeInstanceOf(ProxyTranscodeError);
|
|
|
|
now.mockReturnValue(1_000 + 60_001);
|
|
const retry = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
expect(calls).toHaveLength(2);
|
|
succeed(calls[1]!);
|
|
await expect(retry).resolves.toBeTruthy();
|
|
});
|
|
|
|
// A real ffprobe answers after a macrotask, so a failure lands after a zero wait gave up.
|
|
async function loadWithSlowProbe(
|
|
spawn: SpawnImpl,
|
|
ffmpegPath: () => string | undefined,
|
|
probe: () => Promise<unknown>,
|
|
): Promise<typeof import("./proxyTranscoder.js")> {
|
|
vi.resetModules();
|
|
vi.doMock("node:child_process", () => {
|
|
const mocked = { spawn };
|
|
return { ...mocked, default: mocked };
|
|
});
|
|
vi.doMock("@hyperframes/parsers/ff-binaries", () => ({ findFfBinary: ffmpegPath }));
|
|
vi.doMock("./mediaMetadata.js", () => ({
|
|
probeMediaMetadata: () =>
|
|
new Promise((resolveProbe) => setTimeout(resolveProbe, 5)).then(probe),
|
|
}));
|
|
return import("./proxyTranscoder.js");
|
|
}
|
|
|
|
const sleep = (ms: number) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
|
|
it("keeps an environment failure briefly, so an ask that stopped waiting hears it next", async () => {
|
|
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
const { spawn, calls } = createSpawnSpy();
|
|
let ffmpegPath: string | undefined;
|
|
const { resolveProxy, waitForProxy, ProxyWaitTimeoutError } = await loadWithSlowProbe(
|
|
spawn,
|
|
() => ffmpegPath,
|
|
async () => ({ kind: "video", color: { isHdr: false } }),
|
|
);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
await expect(waitForProxy(resolveProxy(projectDir, sourcePath), 0)).rejects.toBeInstanceOf(
|
|
ProxyWaitTimeoutError,
|
|
);
|
|
await sleep(30);
|
|
await expect(waitForProxy(resolveProxy(projectDir, sourcePath), 0)).rejects.toThrow(
|
|
"ffmpeg binary not found",
|
|
);
|
|
|
|
ffmpegPath = FFMPEG_PATH;
|
|
now.mockReturnValue(1_000 + 10_001);
|
|
const retry = resolveProxy(projectDir, sourcePath);
|
|
await sleep(30);
|
|
expect(calls).toHaveLength(1);
|
|
succeed(calls[0]!);
|
|
await expect(retry).resolves.toBeTruthy();
|
|
now.mockRestore();
|
|
});
|
|
|
|
it("remembers a failure that is not a transcode error, so it is not retried on every ask", async () => {
|
|
const { spawn } = createSpawnSpy();
|
|
const probe = vi.fn(async () => {
|
|
throw new Error("EBUSY: file locked");
|
|
});
|
|
const { resolveProxy, waitForProxy, ProxyWaitTimeoutError } = await loadWithSlowProbe(
|
|
spawn,
|
|
() => FFMPEG_PATH,
|
|
probe,
|
|
);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
await expect(waitForProxy(resolveProxy(projectDir, sourcePath), 0)).rejects.toBeInstanceOf(
|
|
ProxyWaitTimeoutError,
|
|
);
|
|
await sleep(30);
|
|
await expect(waitForProxy(resolveProxy(projectDir, sourcePath), 0)).rejects.toThrow("EBUSY");
|
|
expect(probe).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("marks a project's copy as in flight, and moves the mark once it lands", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, proxyActivityMark } = await loadWithSlowProbe(
|
|
spawn,
|
|
() => FFMPEG_PATH,
|
|
async () => ({ kind: "video", color: { isHdr: false } }),
|
|
);
|
|
const projectDir = tmpProject();
|
|
const otherProjectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
const before = proxyActivityMark(projectDir);
|
|
|
|
const copy = resolveProxy(projectDir, sourcePath);
|
|
expect(proxyActivityMark(projectDir)).toBeNull();
|
|
expect(proxyActivityMark(otherProjectDir)).toBe(before);
|
|
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
|
succeed(calls[0]!);
|
|
await copy;
|
|
expect(proxyActivityMark(projectDir)).not.toBeNull();
|
|
expect(proxyActivityMark(projectDir)).not.toBe(before);
|
|
expect(proxyActivityMark(join(projectDir, "missing"))).not.toBeNull();
|
|
});
|
|
|
|
it("rejects sources outside the project before probing or spawning", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, ProxySourceOutsideProjectError } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const outsideDir = tmpProject();
|
|
const sourcePath = join(outsideDir, "outside.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
await expect(resolveProxy(projectDir, sourcePath)).rejects.toBeInstanceOf(
|
|
ProxySourceOutsideProjectError,
|
|
);
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("proxies an external target reached through an in-project symlink", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const outsideDir = tmpProject();
|
|
const outsidePath = join(outsideDir, "outside.mov");
|
|
const sourcePath = join(projectDir, "linked.mov");
|
|
writeFileSync(outsidePath, "source-bytes");
|
|
symlinkSync(outsidePath, sourcePath);
|
|
|
|
const cachePath = getProxyCachePath(projectDir, sourcePath);
|
|
const result = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.args).toContain(realpathSync(outsidePath));
|
|
succeed(calls[0]!);
|
|
await expect(result).resolves.toBe(cachePath);
|
|
expect(cachePath.startsWith(join(realpathSync(projectDir), ".transcode-cache"))).toBe(true);
|
|
});
|
|
|
|
it("retries after the source file changes (mtime in the cache key invalidates the remembered failure)", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, ProxyTranscodeError } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
const past = new Date(Date.now() - 60_000);
|
|
utimesSync(sourcePath, past, past);
|
|
|
|
const first = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
fail(calls[0]!, 1, "boom");
|
|
await expect(first).rejects.toBeInstanceOf(ProxyTranscodeError);
|
|
|
|
// Re-exported file (new mtime) → new key → a fresh transcode attempt.
|
|
const future = new Date(Date.now() + 60_000);
|
|
utimesSync(sourcePath, future, future);
|
|
const retry = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
expect(calls).toHaveLength(2);
|
|
succeed(calls[1]!);
|
|
await expect(retry).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("throws a typed error when ffmpeg cannot be resolved, without spawning", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, ProxyTranscodeError } = await loadModule(spawn, undefined);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
await expect(resolveProxy(projectDir, sourcePath)).rejects.toBeInstanceOf(ProxyTranscodeError);
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("treats an ffmpeg that cannot start as an unusable environment, not a bad source", async () => {
|
|
const { spawn, calls } = createSpawnSpy();
|
|
const { resolveProxy, FfmpegUnavailableError } = await loadModule(spawn, FFMPEG_PATH);
|
|
const projectDir = tmpProject();
|
|
const sourcePath = join(projectDir, "video.mov");
|
|
writeFileSync(sourcePath, "source-bytes");
|
|
|
|
const result = resolveProxy(projectDir, sourcePath);
|
|
await flush();
|
|
calls[0]!.proc.emit("error", new Error("EACCES"));
|
|
|
|
await expect(result).rejects.toBeInstanceOf(FfmpegUnavailableError);
|
|
});
|
|
});
|