mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio-server): transcode bounded H.264 proxies on demand (#2589)
* feat(studio-server): bound the proxy cache with LRU accounting Adds cache accounting and bounded cleanup for transcoded proxies, and keeps .transcode-cache out of git. Standalone: the transcoder consumes it next. * feat(studio-server): transcode bounded H.264 proxies on demand Adds the proxy transcoder: a bounded work queue with per-key dedupe, a hard kill ceiling, TTL'd failure memory so a broken asset is not retried forever, and pixel/color normalization for browser playback. Writes through a temp name and renames on success, so a cache entry is never partial. Carries a TEMP fallow ignoreExports entry: this module lands below its consumers, so a per-PR audit sees its exports as unused until the preview weld arrives. The entry is dropped there.
This commit is contained in:
@@ -145,6 +145,29 @@
|
||||
"file": "packages/studio/src/utils/studioHelpers.ts",
|
||||
"exports": ["resolveDroppedAssetDimensions"],
|
||||
},
|
||||
// TEMP (transparent-proxy stack): proxyTranscoder is carved in below its
|
||||
// consumers, which land upstack (mediaProxyPreview + the preview route,
|
||||
// then the CLI surfaces). With no importer yet, a per-PR audit against the
|
||||
// merge base sees the module's whole export surface as unused. Same shape
|
||||
// as the drawElementService entry below. Safe to drop once the preview
|
||||
// weld lands; the stack's final slice greps for zero TEMP entries.
|
||||
{
|
||||
"file": "packages/studio-server/src/helpers/proxyTranscoder.ts",
|
||||
"exports": [
|
||||
"PROXY_PARAMS_VERSION",
|
||||
"TRANSCODE_TIMEOUT_MS",
|
||||
"DEFAULT_PROXY_WAIT_TIMEOUT_MS",
|
||||
"ProxyTranscodeError",
|
||||
"FfmpegMissingFilterError",
|
||||
"ProxyCapacityError",
|
||||
"ProxySourceOutsideProjectError",
|
||||
"ProxyWaitTimeoutError",
|
||||
"waitForProxy",
|
||||
"getProxyCachePath",
|
||||
"clearFailedTranscodesForTest",
|
||||
"resolveProxy",
|
||||
],
|
||||
},
|
||||
// drawElementService is the bottom of the fast-capture Graphite stack
|
||||
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
|
||||
// a per-PR audit diffing against the merge base sees these exports as
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const FFMPEG_PATH = "/usr/bin/ffmpeg";
|
||||
// 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 SpawnCall = { command: string; args: string[]; proc: FakeProc };
|
||||
type SpawnImpl = (command: string, args: string[]) => FakeProc;
|
||||
|
||||
function createSpawnSpy(): { spawn: SpawnImpl; calls: SpawnCall[] } {
|
||||
const calls: SpawnCall[] = [];
|
||||
const spawn: SpawnImpl = (command, args) => {
|
||||
const proc = createFakeProc();
|
||||
calls.push({ command, args, 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();
|
||||
}
|
||||
|
||||
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,
|
||||
): 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 },
|
||||
}),
|
||||
}));
|
||||
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("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"]);
|
||||
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");
|
||||
|
||||
succeed(calls[1]!);
|
||||
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("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);
|
||||
});
|
||||
|
||||
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, "ffmpeg: unsupported codec");
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(ProxyTranscodeError);
|
||||
await expect(resultPromise).rejects.toMatchObject({
|
||||
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);
|
||||
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();
|
||||
});
|
||||
|
||||
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("rejects an in-project symlink whose target escapes the project", async () => {
|
||||
const { spawn, calls } = createSpawnSpy();
|
||||
const { resolveProxy, ProxySourceOutsideProjectError } = 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);
|
||||
|
||||
await expect(resolveProxy(projectDir, sourcePath)).rejects.toBeInstanceOf(
|
||||
ProxySourceOutsideProjectError,
|
||||
);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
utimesSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
||||
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
||||
import { probeMediaMetadata } from "./mediaMetadata.js";
|
||||
import { cleanupProxyCache } from "./proxyCache.js";
|
||||
|
||||
/**
|
||||
* Transcodes browser-hostile local video sources (HEVC, ProRes, ...) into a
|
||||
* cached, seekable H.264 authoring proxy. Consumed by the preview/play/static
|
||||
* project routes (U3/U4) to serve a `?hf-proxy=h264` request; never used on
|
||||
* the render path (render always sees the original file).
|
||||
*
|
||||
* IMPORTANT — request-lifecycle detachment: nothing here accepts or wires an
|
||||
* AbortSignal. `resolveProxy` returns a promise shared by every concurrent
|
||||
* caller for the same cache key (in-flight dedupe below); if a route handler
|
||||
* killed the ffmpeg child on client abort (page reload, HMR), every other
|
||||
* caller waiting on that same promise would fail too, and the next request
|
||||
* would restart a transcode that may have been minutes into a long asset.
|
||||
* Callers MUST let the child run to completion regardless of request
|
||||
* cancellation and simply let the held response also abort — the cache
|
||||
* entry still lands for the next request.
|
||||
*/
|
||||
|
||||
export const PROXY_PARAMS_VERSION = "v2";
|
||||
|
||||
const CACHE_DIR_NAME = ".transcode-cache";
|
||||
|
||||
function boundedEnvInteger(name: string, fallback: number, min: number, max: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
|
||||
}
|
||||
|
||||
// ffmpeg is internally multithreaded, so two concurrent proxy encodes already
|
||||
// saturate a typical laptop. Operators of shared/large machines may tune the
|
||||
// bounded values without patching the package; invalid values fail safe.
|
||||
const MAX_CONCURRENT_TRANSCODES = boundedEnvInteger("HYPERFRAMES_PROXY_MAX_CONCURRENCY", 2, 1, 16);
|
||||
const MAX_QUEUED_TRANSCODES = boundedEnvInteger("HYPERFRAMES_PROXY_MAX_QUEUE", 8, 0, 256);
|
||||
|
||||
const STDERR_TAIL_MAX_CHARS = 4000;
|
||||
export const TRANSCODE_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const FAILURE_CACHE_TTL_MS = 60 * 1000;
|
||||
const MAX_FAILURE_CACHE_ENTRIES = 128;
|
||||
export const DEFAULT_PROXY_WAIT_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
export class ProxyTranscodeError extends Error {
|
||||
readonly exitCode: number | null;
|
||||
readonly stderrTail: string;
|
||||
|
||||
constructor(message: string, exitCode: number | null, stderrTail: string) {
|
||||
super(message);
|
||||
this.name = "ProxyTranscodeError";
|
||||
this.exitCode = exitCode;
|
||||
this.stderrTail = stderrTail;
|
||||
}
|
||||
}
|
||||
|
||||
/** "ffmpeg isn't installed" — an environment condition, not a per-source
|
||||
* failure, so it is deliberately NOT remembered by the negative cache below
|
||||
* (installing ffmpeg mid-session must recover without a server restart). */
|
||||
class FfmpegUnavailableError extends ProxyTranscodeError {
|
||||
constructor() {
|
||||
super("ffmpeg binary not found", null, "");
|
||||
}
|
||||
}
|
||||
|
||||
export class FfmpegMissingFilterError extends ProxyTranscodeError {
|
||||
constructor() {
|
||||
super(
|
||||
"HDR proxying requires ffmpeg zscale/tonemap filters (libzimg); install an ffmpeg build with libzimg support",
|
||||
null,
|
||||
"",
|
||||
);
|
||||
this.name = "FfmpegMissingFilterError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyCapacityError extends ProxyTranscodeError {
|
||||
constructor() {
|
||||
super("media proxy queue is full; retry shortly", null, "");
|
||||
this.name = "ProxyCapacityError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxySourceOutsideProjectError extends ProxyTranscodeError {
|
||||
constructor() {
|
||||
super("media proxy source must be inside the project", null, "");
|
||||
this.name = "ProxySourceOutsideProjectError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyWaitTimeoutError extends ProxyTranscodeError {
|
||||
constructor(timeoutMs: number) {
|
||||
super(`media proxy did not become ready within ${timeoutMs}ms`, null, "");
|
||||
this.name = "ProxyWaitTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounds one caller's wait without cancelling the shared in-flight ffmpeg
|
||||
* job. Other preview/publish callers still receive the completed cache entry. */
|
||||
export async function waitForProxy<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs = DEFAULT_PROXY_WAIT_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new ProxyWaitTimeoutError(timeoutMs)), timeoutMs);
|
||||
timer.unref();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key inputs per the plan: source path relative to the project (so the
|
||||
* cache is portable across checkouts at different absolute locations), mtime
|
||||
* and file size (mtime alone can collide on same-second re-exports on
|
||||
* coarse-timestamp filesystems; size catches nearly all such cases at zero
|
||||
* cost), and a params version token so changing the ffmpeg recipe below
|
||||
* invalidates every cached proxy cleanly.
|
||||
*/
|
||||
type CanonicalProxySource = {
|
||||
projectDir: string;
|
||||
sourcePath: string;
|
||||
relativePath: string;
|
||||
};
|
||||
|
||||
function canonicalizeProxySource(
|
||||
projectDir: string,
|
||||
absoluteSourcePath: string,
|
||||
): CanonicalProxySource {
|
||||
const canonicalProjectDir = realpathSync(projectDir);
|
||||
const canonicalSourcePath = realpathSync(absoluteSourcePath);
|
||||
const relPath = relative(canonicalProjectDir, canonicalSourcePath);
|
||||
if (relPath === ".." || relPath.startsWith(`..${sep}`) || isAbsolute(relPath)) {
|
||||
throw new ProxySourceOutsideProjectError();
|
||||
}
|
||||
return {
|
||||
projectDir: canonicalProjectDir,
|
||||
sourcePath: canonicalSourcePath,
|
||||
relativePath: relPath.normalize("NFC"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildProxyCacheKey(source: CanonicalProxySource): string {
|
||||
const stat = statSync(source.sourcePath);
|
||||
return createHash("sha256")
|
||||
.update(`${source.relativePath}\0${stat.mtimeMs}\0${stat.size}\0${PROXY_PARAMS_VERSION}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function getCanonicalProxyCachePath(source: CanonicalProxySource): string {
|
||||
const key = buildProxyCacheKey(source);
|
||||
return join(source.projectDir, CACHE_DIR_NAME, `${key}.mp4`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the absolute path a proxy for this source would live at, without
|
||||
* transcoding anything. Route handlers use this to check cache state (e.g.
|
||||
* for ETag/If-None-Match) before deciding whether to await a transcode.
|
||||
*/
|
||||
export function getProxyCachePath(projectDir: string, absoluteSourcePath: string): string {
|
||||
return getCanonicalProxyCachePath(canonicalizeProxySource(projectDir, absoluteSourcePath));
|
||||
}
|
||||
|
||||
// --- global concurrency limiter -------------------------------------------
|
||||
// ponytail: a bare counter + FIFO wait queue is the whole semaphore; no
|
||||
// dependency pulled in for this. Both element-triggered and pre-warm calls
|
||||
// go through the same `resolveProxy` entry point, so both queue here.
|
||||
|
||||
let activeTranscodes = 0;
|
||||
const waitQueue: Array<() => void> = [];
|
||||
|
||||
function acquireSlot(): Promise<void> {
|
||||
return new Promise((resolveSlot, reject) => {
|
||||
const tryAcquire = (): void => {
|
||||
if (activeTranscodes < MAX_CONCURRENT_TRANSCODES) {
|
||||
activeTranscodes++;
|
||||
resolveSlot();
|
||||
} else {
|
||||
if (waitQueue.length >= MAX_QUEUED_TRANSCODES) {
|
||||
reject(new ProxyCapacityError());
|
||||
return;
|
||||
}
|
||||
waitQueue.push(tryAcquire);
|
||||
}
|
||||
};
|
||||
tryAcquire();
|
||||
});
|
||||
}
|
||||
|
||||
function releaseSlot(): void {
|
||||
activeTranscodes--;
|
||||
const next = waitQueue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
|
||||
// --- per-key in-flight dedupe ----------------------------------------------
|
||||
|
||||
const inFlight = new Map<string, Promise<string>>();
|
||||
|
||||
function maintainProxyCache(cacheDir: string): void {
|
||||
try {
|
||||
cleanupProxyCache(cacheDir, { protectedPaths: new Set(inFlight.keys()) });
|
||||
} catch (error) {
|
||||
// Cache maintenance must never turn a playable preview into an error.
|
||||
console.warn(
|
||||
`[media-proxy] cache cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function markCacheEntryUsed(cachePath: string): void {
|
||||
try {
|
||||
const now = new Date();
|
||||
utimesSync(cachePath, now, now);
|
||||
} catch {
|
||||
// A concurrent cleanup may have removed a stale entry after existsSync;
|
||||
// the normal miss path below will recreate it on the next request.
|
||||
}
|
||||
}
|
||||
|
||||
// --- negative cache ---------------------------------------------------------
|
||||
// A source that failed to transcode fails again identically until the file
|
||||
// changes (the cache key embeds mtime+size, so a re-export invalidates this
|
||||
// naturally). Remembering the failure per key means repeated `?hf-proxy=`
|
||||
// requests for a broken asset rethrow instantly instead of respawning ffmpeg
|
||||
// on every retry the browser makes.
|
||||
interface RememberedFailure {
|
||||
error: ProxyTranscodeError;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const failedTranscodes = new Map<string, RememberedFailure>();
|
||||
|
||||
let hdrFilterCheck: { ffmpegPath: string; promise: Promise<void> } | undefined;
|
||||
|
||||
function ensureHdrFilters(ffmpegPath: string): Promise<void> {
|
||||
if (hdrFilterCheck?.ffmpegPath === ffmpegPath) return hdrFilterCheck.promise;
|
||||
const promise = new Promise<void>((resolveCheck, rejectCheck) => {
|
||||
const proc = spawn(ffmpegPath, ["-hide_banner", "-filters"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
proc.on("error", () => rejectCheck(new FfmpegMissingFilterError()));
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0 || !/\bzscale\b/.test(stdout) || !/\btonemap\b/.test(stdout)) {
|
||||
rejectCheck(new FfmpegMissingFilterError());
|
||||
} else {
|
||||
resolveCheck();
|
||||
}
|
||||
});
|
||||
});
|
||||
hdrFilterCheck = { ffmpegPath, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
function rememberFailure(cachePath: string, error: ProxyTranscodeError): void {
|
||||
failedTranscodes.delete(cachePath);
|
||||
failedTranscodes.set(cachePath, { error, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS });
|
||||
while (failedTranscodes.size > MAX_FAILURE_CACHE_ENTRIES) {
|
||||
const oldest = failedTranscodes.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
failedTranscodes.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: forget remembered transcode failures (module state persists
|
||||
* across tests that don't reload the module). */
|
||||
export function clearFailedTranscodesForTest(): void {
|
||||
failedTranscodes.clear();
|
||||
}
|
||||
|
||||
async function runFfmpeg(sourcePath: string, outputPath: string): Promise<void> {
|
||||
const metadata = await probeMediaMetadata(sourcePath);
|
||||
const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true });
|
||||
if (!ffmpegPath) {
|
||||
throw new FfmpegUnavailableError();
|
||||
}
|
||||
if (metadata.color.isHdr) await ensureHdrFilters(ffmpegPath);
|
||||
const evenScale = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
|
||||
const videoFilter = metadata.color.isHdr
|
||||
? [
|
||||
"zscale=t=linear:npl=100",
|
||||
"tonemap=hable:desat=0",
|
||||
"zscale=p=bt709:t=bt709:m=bt709:r=tv",
|
||||
evenScale,
|
||||
"format=yuv420p",
|
||||
].join(",")
|
||||
: [evenScale, "format=yuv420p"].join(",");
|
||||
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const args = [
|
||||
"-y",
|
||||
"-i",
|
||||
sourcePath,
|
||||
"-vf",
|
||||
videoFilter,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-profile:v",
|
||||
"high",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-colorspace",
|
||||
"bt709",
|
||||
"-color_primaries",
|
||||
"bt709",
|
||||
"-color_trc",
|
||||
"bt709",
|
||||
"-crf",
|
||||
"18",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
outputPath,
|
||||
];
|
||||
|
||||
// Hard ceiling so a hung ffmpeg can never permanently occupy one of the
|
||||
// global transcode slots: the child is killed and the slot released via
|
||||
// the caller's finally. Generous because long assets transcode at
|
||||
// roughly real time; a healthy encode of any authoring asset fits.
|
||||
const proc = spawn(ffmpegPath, args, {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
timeout: TRANSCODE_TIMEOUT_MS,
|
||||
killSignal: "SIGKILL",
|
||||
});
|
||||
let stderrTail = "";
|
||||
proc.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_MAX_CHARS);
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
reject(new ProxyTranscodeError(`failed to spawn ffmpeg: ${err.message}`, null, stderrTail));
|
||||
});
|
||||
proc.on("close", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
} else if (signal) {
|
||||
reject(
|
||||
new ProxyTranscodeError(
|
||||
`ffmpeg killed by ${signal} (timeout ${TRANSCODE_TIMEOUT_MS}ms or external kill)`,
|
||||
null,
|
||||
stderrTail,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(new ProxyTranscodeError(`ffmpeg exited with code ${code}`, code, stderrTail));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function transcodeToCache(absoluteSourcePath: string, cachePath: string): Promise<string> {
|
||||
await acquireSlot();
|
||||
try {
|
||||
// Another caller may have finished (or a pre-warm beat us) while queued.
|
||||
if (existsSync(cachePath)) return cachePath;
|
||||
|
||||
const cacheDir = dirname(cachePath);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const tempPath = join(cacheDir, `.tmp-${randomUUID()}-${basename(cachePath)}`);
|
||||
try {
|
||||
await runFfmpeg(absoluteSourcePath, tempPath);
|
||||
renameSync(tempPath, cachePath);
|
||||
maintainProxyCache(cacheDir);
|
||||
return cachePath;
|
||||
} finally {
|
||||
// No partial files: if anything above threw, remove whatever ffmpeg
|
||||
// may have partially written under the temp name.
|
||||
if (existsSync(tempPath)) unlinkSync(tempPath);
|
||||
}
|
||||
} finally {
|
||||
releaseSlot();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the cached H.264 proxy for `absoluteSourcePath`, transcoding it at
|
||||
* most once per cache key. Concurrent calls for the same key (including a
|
||||
* pre-warm call racing an element-triggered one) share one ffmpeg child and
|
||||
* one promise; calls for different keys queue through the global concurrency
|
||||
* limiter above. Throws `ProxyTranscodeError` on failure (missing ffmpeg or a
|
||||
* nonzero exit) — callers (route handlers) decide how to surface that (502).
|
||||
*/
|
||||
export async function resolveProxy(
|
||||
projectDir: string,
|
||||
absoluteSourcePath: string,
|
||||
): Promise<string> {
|
||||
const source = canonicalizeProxySource(projectDir, absoluteSourcePath);
|
||||
const cachePath = getCanonicalProxyCachePath(source);
|
||||
if (existsSync(cachePath)) {
|
||||
markCacheEntryUsed(cachePath);
|
||||
maintainProxyCache(dirname(cachePath));
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
const rememberedFailure = failedTranscodes.get(cachePath);
|
||||
if (rememberedFailure) {
|
||||
if (rememberedFailure.expiresAt > Date.now()) throw rememberedFailure.error;
|
||||
failedTranscodes.delete(cachePath);
|
||||
}
|
||||
|
||||
const existing = inFlight.get(cachePath);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = transcodeToCache(source.sourcePath, cachePath)
|
||||
.catch((err: unknown) => {
|
||||
if (
|
||||
err instanceof ProxyTranscodeError &&
|
||||
!(err instanceof FfmpegUnavailableError) &&
|
||||
!(err instanceof FfmpegMissingFilterError) &&
|
||||
!(err instanceof ProxyCapacityError) &&
|
||||
!(err instanceof ProxySourceOutsideProjectError)
|
||||
) {
|
||||
rememberFailure(cachePath, err);
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(cachePath);
|
||||
});
|
||||
inFlight.set(cachePath, promise);
|
||||
return promise;
|
||||
}
|
||||
Reference in New Issue
Block a user