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:
Miguel Ángel
2026-07-16 21:08:03 -04:00
committed by GitHub
parent 1d3d20450d
commit 9d148d288a
3 changed files with 965 additions and 0 deletions
+23
View File
@@ -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;
}