mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user