mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio-server): probe media codec facts for proxy decisions (#2587)
* feat(studio-server): probe media codec facts for proxy decisions Adds the codec manifest: one ffprobe-backed answer to what codec an asset uses, whether a browser can decode it, and whether it carries alpha. Migrates the existing prober to the shared ff-binaries resolver and to async execFile so a scan pool runs off the event loop. No consumer yet; the preview weld wires it up later in the stack. * fix(studio-server): honor injected ffprobe runners
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { FfprobeRunner } from "./mediaMetadata.js";
|
||||
import {
|
||||
BROWSER_HOSTILE_CODECS,
|
||||
decideMediaProxyEligibility,
|
||||
createMediaCodecProbeCache,
|
||||
probeAssetCodec,
|
||||
scanProjectMediaCodecMap,
|
||||
} from "./mediaCodecMap.js";
|
||||
|
||||
// Any real, existing file works as a stand-in ffprobe path — the runner
|
||||
// passed to probeMediaMetadata is what's actually invoked, mirroring
|
||||
// packages/lint/src/project.test.ts's hevc_preview_codec pattern (keeps this
|
||||
// test independent of whether the host actually has ffprobe installed).
|
||||
const FAKE_FFPROBE_PATH = process.execPath;
|
||||
|
||||
function makeRunner(
|
||||
codecByPath: Record<string, string | { codecName: string; pixFmt?: string }>,
|
||||
): FfprobeRunner {
|
||||
return (_command, args) => {
|
||||
const filePath = args[args.length - 1] ?? "";
|
||||
const entry = codecByPath[filePath];
|
||||
const normalized = typeof entry === "string" ? { codecName: entry } : entry;
|
||||
const streams = normalized
|
||||
? [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: normalized.codecName,
|
||||
pix_fmt: normalized.pixFmt,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return { status: 0, stdout: JSON.stringify({ streams }), stderr: "" };
|
||||
};
|
||||
}
|
||||
|
||||
function countingRunner(runner: FfprobeRunner): {
|
||||
runner: FfprobeRunner;
|
||||
calls: () => number;
|
||||
} {
|
||||
let calls = 0;
|
||||
return {
|
||||
runner: (command, args, options) => {
|
||||
calls++;
|
||||
return runner(command, args, options);
|
||||
},
|
||||
calls: () => calls,
|
||||
};
|
||||
}
|
||||
|
||||
function videoHtml(...srcs: string[]): string {
|
||||
const tags = srcs
|
||||
.map(
|
||||
(src, i) =>
|
||||
`<video id="v${i}" class="clip" src="${src}" muted data-start="0" data-duration="5"></video>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">${tags}</div></body></html>`;
|
||||
}
|
||||
|
||||
let dirs: string[] = [];
|
||||
|
||||
function tmpProject(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-media-codec-map-test-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HYPERFRAMES_FFPROBE_PATH = FAKE_FFPROBE_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
||||
dirs = [];
|
||||
});
|
||||
|
||||
describe("probeAssetCodec", () => {
|
||||
it("reports an HEVC asset as browser-hostile with the pinned representative mime", async () => {
|
||||
const project = tmpProject();
|
||||
const videoPath = join(project, "clip.mp4");
|
||||
writeFileSync(videoPath, "fake video bytes");
|
||||
|
||||
const facts = await probeAssetCodec(videoPath, makeRunner({ [videoPath]: "hevc" }));
|
||||
|
||||
expect(facts).toEqual({
|
||||
codecName: "hevc",
|
||||
browserHostile: true,
|
||||
representativeMime: BROWSER_HOSTILE_CODECS.hevc,
|
||||
hasAlpha: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an H.264 asset as not browser-hostile", async () => {
|
||||
const project = tmpProject();
|
||||
const videoPath = join(project, "clip.mp4");
|
||||
writeFileSync(videoPath, "fake video bytes");
|
||||
|
||||
const facts = await probeAssetCodec(videoPath, makeRunner({ [videoPath]: "h264" }));
|
||||
|
||||
expect(facts).toEqual({
|
||||
codecName: "h264",
|
||||
browserHostile: false,
|
||||
representativeMime: null,
|
||||
hasAlpha: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats VP9 as conditionally hostile so unsupported browsers can request a proxy", async () => {
|
||||
const project = tmpProject();
|
||||
const videoPath = join(project, "clip.webm");
|
||||
writeFileSync(videoPath, "fake video bytes");
|
||||
|
||||
const facts = await probeAssetCodec(videoPath, makeRunner({ [videoPath]: "vp9" }));
|
||||
|
||||
expect(facts).toEqual({
|
||||
codecName: "vp9",
|
||||
browserHostile: true,
|
||||
representativeMime: BROWSER_HOSTILE_CODECS.vp9,
|
||||
hasAlpha: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports ProRes as browser-hostile with no representative mime", async () => {
|
||||
const project = tmpProject();
|
||||
const videoPath = join(project, "clip.mov");
|
||||
writeFileSync(videoPath, "fake video bytes");
|
||||
|
||||
const facts = await probeAssetCodec(videoPath, makeRunner({ [videoPath]: "prores" }));
|
||||
|
||||
expect(facts).toEqual({
|
||||
codecName: "prores",
|
||||
browserHostile: true,
|
||||
representativeMime: null,
|
||||
hasAlpha: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags an alpha-bearing pix_fmt (ProRes 4444) with hasAlpha", async () => {
|
||||
const project = tmpProject();
|
||||
const videoPath = join(project, "clip.mov");
|
||||
writeFileSync(videoPath, "fake video bytes");
|
||||
|
||||
const facts = await probeAssetCodec(
|
||||
videoPath,
|
||||
makeRunner({
|
||||
[videoPath]: { codecName: "prores", pixFmt: "yuva444p10le" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(facts).toEqual({
|
||||
codecName: "prores",
|
||||
browserHostile: true,
|
||||
representativeMime: null,
|
||||
hasAlpha: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null (never throws) when ffprobe is unresolvable", async () => {
|
||||
const project = tmpProject();
|
||||
const videoPath = join(project, "clip.mp4");
|
||||
writeFileSync(videoPath, "fake video bytes");
|
||||
process.env.HYPERFRAMES_FFPROBE_PATH = join(project, "missing-ffprobe");
|
||||
|
||||
await expect(probeAssetCodec(videoPath)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideMediaProxyEligibility", () => {
|
||||
it("allows only hostile opaque video through the H.264 proxy path", () => {
|
||||
expect(
|
||||
decideMediaProxyEligibility({
|
||||
codecName: "hevc",
|
||||
browserHostile: true,
|
||||
representativeMime: null,
|
||||
hasAlpha: false,
|
||||
}),
|
||||
).toEqual({ eligible: true });
|
||||
});
|
||||
|
||||
it("rejects alpha and browser-safe sources before transcoding", () => {
|
||||
expect(
|
||||
decideMediaProxyEligibility({
|
||||
codecName: "prores",
|
||||
browserHostile: true,
|
||||
representativeMime: null,
|
||||
hasAlpha: true,
|
||||
}),
|
||||
).toEqual({ eligible: false, reason: "alpha_source" });
|
||||
expect(
|
||||
decideMediaProxyEligibility({
|
||||
codecName: "h264",
|
||||
browserHostile: false,
|
||||
representativeMime: null,
|
||||
hasAlpha: false,
|
||||
}),
|
||||
).toEqual({ eligible: false, reason: "browser_safe_codec" });
|
||||
expect(decideMediaProxyEligibility(null)).toEqual({
|
||||
eligible: false,
|
||||
reason: "unknown_codec",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanProjectMediaCodecMap", () => {
|
||||
it("omits an asset from the map (no throw) when ffprobe is unresolvable", async () => {
|
||||
const project = tmpProject();
|
||||
writeFileSync(join(project, "clip.mp4"), "fake video bytes");
|
||||
process.env.HYPERFRAMES_FFPROBE_PATH = join(project, "missing-ffprobe");
|
||||
|
||||
const map = await scanProjectMediaCodecMap(project, [{ html: videoHtml("clip.mp4") }]);
|
||||
|
||||
expect(map).toEqual({});
|
||||
});
|
||||
|
||||
it("keys the map by project-root-relative URL pathnames: decoded, forward-slash, leading-slash", async () => {
|
||||
const project = tmpProject();
|
||||
mkdirSync(join(project, "assets", "sub"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", "sub", "clip.mp4"), "fake video bytes");
|
||||
writeFileSync(join(project, "assets", "my clip.mp4"), "fake video bytes");
|
||||
|
||||
const html = videoHtml("assets/sub/clip.mp4", "assets/my%20clip.mp4");
|
||||
const map = await scanProjectMediaCodecMap(project, [{ html }], {
|
||||
runner: makeRunner({
|
||||
[join(project, "assets", "sub", "clip.mp4")]: "hevc",
|
||||
[join(project, "assets", "my clip.mp4")]: "h264",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(Object.keys(map)).toEqual(["/assets/sub/clip.mp4"]);
|
||||
expect(map["/assets/sub/clip.mp4"]?.browserHostile).toBe(true);
|
||||
expect(map["/assets/my clip.mp4"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rewrites a sub-composition's ../-traversing src via compSrcPath before resolving (rewriteAssetPath)", async () => {
|
||||
const project = tmpProject();
|
||||
mkdirSync(join(project, "assets"), { recursive: true });
|
||||
mkdirSync(join(project, "compositions"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", "clip.mp4"), "fake video bytes");
|
||||
|
||||
const map = await scanProjectMediaCodecMap(
|
||||
project,
|
||||
[
|
||||
{
|
||||
html: videoHtml("../assets/clip.mp4"),
|
||||
compSrcPath: "compositions/scene.html",
|
||||
},
|
||||
],
|
||||
{ runner: makeRunner({ [join(project, "assets", "clip.mp4")]: "hevc" }) },
|
||||
);
|
||||
|
||||
// The key is root-relative (what the served DOM resolves to), not the
|
||||
// sub-composition-relative authored src.
|
||||
expect(Object.keys(map)).toEqual(["/assets/clip.mp4"]);
|
||||
expect(map["/assets/clip.mp4"]?.browserHostile).toBe(true);
|
||||
});
|
||||
|
||||
it("caches by path + mtime + size, invalidating same-mtime re-exports and touched files", async () => {
|
||||
const project = tmpProject();
|
||||
const videoPath = join(project, "clip.mp4");
|
||||
writeFileSync(videoPath, "fake video bytes");
|
||||
const pinnedMtime = new Date(Date.now() - 60_000);
|
||||
utimesSync(videoPath, pinnedMtime, pinnedMtime);
|
||||
const html = videoHtml("clip.mp4");
|
||||
const cache = createMediaCodecProbeCache();
|
||||
const probe = countingRunner(makeRunner({ [videoPath]: "hevc" }));
|
||||
|
||||
const first = await scanProjectMediaCodecMap(project, [{ html }], {
|
||||
cache,
|
||||
runner: probe.runner,
|
||||
});
|
||||
expect(first["/clip.mp4"]?.codecName).toBe("hevc");
|
||||
expect(probe.calls()).toBe(1);
|
||||
|
||||
const second = await scanProjectMediaCodecMap(project, [{ html }], {
|
||||
cache,
|
||||
runner: probe.runner,
|
||||
});
|
||||
expect(second["/clip.mp4"]?.codecName).toBe("hevc");
|
||||
expect(probe.calls()).toBe(1);
|
||||
|
||||
writeFileSync(videoPath, "larger fake video bytes");
|
||||
utimesSync(videoPath, pinnedMtime, pinnedMtime);
|
||||
const resized = await scanProjectMediaCodecMap(project, [{ html }], {
|
||||
cache,
|
||||
runner: probe.runner,
|
||||
});
|
||||
expect(resized["/clip.mp4"]?.codecName).toBe("hevc");
|
||||
expect(probe.calls()).toBe(2);
|
||||
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
utimesSync(videoPath, future, future);
|
||||
|
||||
const third = await scanProjectMediaCodecMap(project, [{ html }], {
|
||||
cache,
|
||||
runner: probe.runner,
|
||||
});
|
||||
expect(third["/clip.mp4"]?.codecName).toBe("hevc");
|
||||
expect(probe.calls()).toBe(3);
|
||||
});
|
||||
|
||||
it("bounds the long-lived probe cache while retaining the newest assets", async () => {
|
||||
const project = tmpProject();
|
||||
const cache = createMediaCodecProbeCache();
|
||||
const paths = Array.from({ length: 513 }, (_, index) => `clip-${index}.mp4`);
|
||||
const codecByPath: Record<string, string> = {};
|
||||
for (const path of paths) {
|
||||
const absolutePath = join(project, path);
|
||||
writeFileSync(absolutePath, "fake video bytes");
|
||||
codecByPath[absolutePath] = "hevc";
|
||||
}
|
||||
|
||||
await scanProjectMediaCodecMap(project, [{ html: videoHtml(...paths) }], {
|
||||
cache,
|
||||
runner: makeRunner(codecByPath),
|
||||
});
|
||||
|
||||
expect(cache.size).toBe(512);
|
||||
expect(paths.slice(0, 8).some((path) => !cache.has(join(project, path)))).toBe(true);
|
||||
expect(cache.has(join(project, "clip-512.mp4"))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user