fix(studio-server): probe each proxied clip once instead of on every request (#4557)

* fix(studio-server): probe each proxied clip once instead of on every request

* fix(studio-server): retry a failed codec probe and use the server probe cache

* fix(studio-server): probe again when a clip symlink is repointed

* test(studio-server): pin equal mtimes in the symlink retarget test
This commit is contained in:
Miguel Ángel
2026-09-27 02:06:00 +00:00
committed by GitHub
parent 16dc05e9fb
commit 9c56240bf5
4 changed files with 116 additions and 21 deletions
@@ -172,6 +172,19 @@ describe("probeAssetCodec", () => {
await expect(probeAssetCodec(videoPath)).resolves.toBeNull();
});
it("probes again after ffprobe failed instead of caching the failure", async () => {
const project = tmpProject();
const videoPath = join(project, "clip.mp4");
writeFileSync(videoPath, "fake video bytes");
const cache = createMediaCodecProbeCache();
process.env.HYPERFRAMES_FFPROBE_PATH = join(project, "missing-ffprobe");
await expect(probeAssetCodec(videoPath, undefined, cache)).resolves.toBeNull();
const facts = await probeAssetCodec(videoPath, makeRunner({ [videoPath]: "hevc" }), cache);
expect(facts?.codecName).toBe("hevc");
});
});
describe("BROWSER_HOSTILE_CODECS representative mimes", () => {
@@ -1,4 +1,4 @@
import { existsSync, statSync } from "node:fs";
import { existsSync, realpathSync, statSync } from "node:fs";
import { relative, resolve, sep } from "node:path";
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import {
@@ -191,26 +191,24 @@ function codecFactsFor(codecName: string, hasAlpha: boolean): AssetCodecFacts {
};
}
/**
* Probe a single video asset. Best-effort: ffprobe missing, erroring, or
* finding no video stream resolves to `null` (asset omitted by the caller),
* never a throw. Async so a pool of probes runs concurrently (the default
* runner is `execFile`-based).
*/
export async function probeAssetCodec(
/** `undefined` when ffprobe itself failed: no answer about the file, so nothing to cache. */
async function probeCodecFacts(
filePath: string,
runner?: FfprobeRunner,
): Promise<AssetCodecFacts | null> {
): Promise<AssetCodecFacts | null | undefined> {
const metadata = runner
? await probeMediaMetadata(filePath, runner)
: await probeMediaMetadata(filePath);
if (metadata.kind !== "video" || metadata.probeError) return null;
if (metadata.probeError) return undefined;
if (metadata.kind !== "video") return null;
const codecName = metadata.color.codecName;
if (!codecName) return null;
return codecFactsFor(codecName, pixelFormatHasAlpha(metadata.color.pixelFormat));
}
interface CachedAssetProbe {
/** The file a symlinked path pointed at, so retargeting the link is a miss. */
target: string;
mtimeMs: number;
size: number;
facts: AssetCodecFacts | null;
@@ -224,10 +222,7 @@ export function createMediaCodecProbeCache(): MediaCodecProbeCache {
return new Map();
}
// Used when a caller doesn't pass its own cache — still correct (probes every
// time a fresh Map would), but callers that want the mtime-cache benefit
// across repeated scans (the studio preview route, etc.) should construct
// and hold their own cache via `createMediaCodecProbeCache`.
// Shared by every route and scan in this process that does not pass its own cache.
const defaultProbeCache: MediaCodecProbeCache = new Map();
const MAX_PROBE_CACHE_ENTRIES = 512;
@@ -245,24 +240,36 @@ function rememberProbeResult(
cache.set(filePath, result);
}
async function probeAssetCodecCached(
/**
* Codec facts for one video, cached per path until its target, mtime or size changes; a hit skips
* `runner`. Never throws: no video stream or a failed probe gives `null`, and a failed probe is not cached.
*/
export async function probeAssetCodec(
filePath: string,
cache: MediaCodecProbeCache,
runner?: FfprobeRunner,
cache: MediaCodecProbeCache = defaultProbeCache,
): Promise<AssetCodecFacts | null> {
let stat: ReturnType<typeof statSync>;
let target: string;
try {
stat = statSync(filePath);
target = realpathSync(filePath);
} catch {
return null;
}
const cached = cache.get(filePath);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
if (
cached &&
cached.target === target &&
cached.mtimeMs === stat.mtimeMs &&
cached.size === stat.size
) {
rememberProbeResult(cache, filePath, cached);
return cached.facts;
}
const facts = await probeAssetCodec(filePath, runner);
rememberProbeResult(cache, filePath, { mtimeMs: stat.mtimeMs, size: stat.size, facts });
const facts = await probeCodecFacts(filePath, runner);
if (facts === undefined) return null;
rememberProbeResult(cache, filePath, { target, mtimeMs: stat.mtimeMs, size: stat.size, facts });
return facts;
}
@@ -369,7 +376,7 @@ export async function scanProjectMediaCodecMap(
const index = nextIndex++;
const entry = entries[index];
if (!entry) break;
facts[index] = await probeAssetCodecCached(entry[0], cache, options.runner);
facts[index] = await probeAssetCodec(entry[0], options.runner, cache);
}
}),
);
@@ -8,9 +8,11 @@ import {
mkdtempSync,
openSync,
readFileSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
utimesSync,
writeFileSync,
writeSync,
} from "node:fs";
@@ -1480,6 +1482,79 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
});
});
describe("hf-proxy codec probe", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("../helpers/mediaMetadata.js");
vi.doUnmock("../helpers/proxyTranscoder.js");
});
async function appWithProbe(projectDir: string, codecOf: (path: string) => string) {
const proxyPath = join(projectDir, "proxy.mp4");
writeFileSync(proxyPath, "proxy-bytes");
const probeMediaMetadata = vi.fn(async (path: string) => ({
kind: "video" as const,
color: { codecName: codecOf(path), pixelFormat: "yuv420p" },
}));
vi.resetModules();
vi.doMock("../helpers/mediaMetadata.js", async () => ({
...(await vi.importActual<typeof import("../helpers/mediaMetadata.js")>(
"../helpers/mediaMetadata.js",
)),
probeMediaMetadata,
}));
vi.doMock("../helpers/proxyTranscoder.js", async () => ({
...(await vi.importActual<typeof import("../helpers/proxyTranscoder.js")>(
"../helpers/proxyTranscoder.js",
)),
resolveProxy: async () => proxyPath,
}));
const { registerPreviewRoutes: register } = await import("./preview.js");
const app = new Hono();
register(app, createAdapter(projectDir));
const proxy = (file: string) =>
app.request(`http://localhost/projects/demo/preview/${file}?hf-proxy=h264`);
return { proxy, probeMediaMetadata };
}
it("runs ffprobe once for repeated proxy requests of the same unchanged clip", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const { proxy, probeMediaMetadata } = await appWithProbe(projectDir, () => "hevc");
expect((await proxy("clip.mp4")).status).toBe(200);
expect((await proxy("clip.mp4")).status).toBe(200);
expect(probeMediaMetadata).toHaveBeenCalledTimes(1);
});
it("probes again when a clip's symlink is repointed at a file with the same mtime and size", async () => {
const projectDir = createProjectDir();
const externalDir = mkdtempSync(join(tmpdir(), "hf-preview-retarget-"));
tempDirs.push(externalDir);
const hevc = join(externalDir, "hevc.mp4");
const h264 = join(externalDir, "h264.mp4");
writeFileSync(hevc, "same-size-a");
writeFileSync(h264, "same-size-b");
const sameTime = new Date("2026-01-01T00:00:00Z");
utimesSync(hevc, sameTime, sameTime);
utimesSync(h264, sameTime, sameTime);
const link = join(projectDir, "clip.mp4");
if (!tryCreateSymlink(hevc, link, "file")) return;
const { proxy } = await appWithProbe(projectDir, (path) =>
realpathSync(path) === realpathSync(hevc) ? "hevc" : "h264",
);
expect((await proxy("clip.mp4")).status).toBe(200);
rmSync(link);
symlinkSync(h264, link, "file");
const retargeted = await proxy("clip.mp4");
expect(retargeted.status).toBe(422);
expect(await retargeted.text()).toContain("browser_safe_codec");
});
});
describe("preview asset byte ranges", () => {
it("streams a slice of a media file too large to read whole", async () => {
// A sparse 3 GiB file costs no disk. readFileSync refuses anything over
+1 -1
View File
@@ -564,7 +564,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
) {
return c.text("not found", 404);
}
const facts = await probeAssetCodec(file);
const facts = await probeAssetCodec(file, undefined, mediaCodecProbeCache);
const eligibility = decideMediaProxyEligibility(facts);
if (!eligibility.eligible) {
return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);