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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { relative, resolve, sep } from "node:path";
|
||||
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
||||
import {
|
||||
cleanAssetUrl,
|
||||
isRemoteOrInlineUrl,
|
||||
maskNonScannableRanges,
|
||||
resolveLocalAssetCandidates,
|
||||
} from "@hyperframes/parsers/asset-resolution";
|
||||
import { pixelFormatHasAlpha, probeMediaMetadata, type FfprobeRunner } from "./mediaMetadata.js";
|
||||
|
||||
/**
|
||||
* One reusable answer to "what codec is this asset, and is it browser-hostile?",
|
||||
* built on top of `mediaMetadata.ts`'s ffprobe-backed prober so studio-server
|
||||
* probes each asset once instead of running a second prober.
|
||||
*/
|
||||
|
||||
export interface AssetCodecFacts {
|
||||
codecName: string;
|
||||
browserHostile: boolean;
|
||||
/** Coarse `canPlayType()` input; `null` when not applicable (safe codec) or
|
||||
* when no representative mime exists (ProRes: browsers never decode it, so
|
||||
* the runtime always proxies rather than probing `canPlayType`). */
|
||||
representativeMime: string | null;
|
||||
/** Source carries an alpha channel (ffprobe pix_fmt). Alpha sources are
|
||||
* never proxied — an H.264 proxy would destroy the transparency (e.g.
|
||||
* ProRes 4444 alpha). */
|
||||
hasAlpha: boolean;
|
||||
}
|
||||
|
||||
/** Server-root-relative URL pathname -> that asset's codec facts. */
|
||||
export type MediaCodecMap = Record<string, AssetCodecFacts>;
|
||||
|
||||
/**
|
||||
* Browser-hostile codec table v1. One exported constant so extending it is a
|
||||
* one-line change. `ffprobe` cannot emit exact RFC 6381 codec strings, so
|
||||
* these `representativeMime` values are deliberately coarse (a false
|
||||
* positive costs one proxy transcode, never correctness; a false negative is
|
||||
* rescued by the runtime's reactive zero-videoWidth swap).
|
||||
*/
|
||||
export const BROWSER_HOSTILE_CODECS: Record<string, string | null> = {
|
||||
hevc: 'video/mp4; codecs="hvc1.1.6.L120.B0"',
|
||||
prores: null,
|
||||
av1: 'video/mp4; codecs="av01.0.08M.08"',
|
||||
// VP9 is browser-dependent: Chrome generally decodes it while Safari
|
||||
// support varies. Treat it as conditional so canPlayType keeps the
|
||||
// original where supported and transparently proxies it where unsupported.
|
||||
vp9: 'video/webm; codecs="vp09.00.10.08"',
|
||||
};
|
||||
|
||||
export type MediaProxyIneligibilityReason = "alpha_source" | "browser_safe_codec" | "unknown_codec";
|
||||
|
||||
export type MediaProxyEligibility =
|
||||
| { eligible: true }
|
||||
| { eligible: false; reason: MediaProxyIneligibilityReason };
|
||||
|
||||
/** Single policy gate shared by proactive scans and on-demand proxy routes. */
|
||||
export function decideMediaProxyEligibility(facts: AssetCodecFacts | null): MediaProxyEligibility {
|
||||
if (!facts) return { eligible: false, reason: "unknown_codec" };
|
||||
if (facts.hasAlpha) return { eligible: false, reason: "alpha_source" };
|
||||
if (!facts.browserHostile) return { eligible: false, reason: "browser_safe_codec" };
|
||||
return { eligible: true };
|
||||
}
|
||||
|
||||
function codecFactsFor(codecName: string, hasAlpha: boolean): AssetCodecFacts {
|
||||
const isHostile = Object.hasOwn(BROWSER_HOSTILE_CODECS, codecName);
|
||||
return {
|
||||
codecName,
|
||||
browserHostile: isHostile,
|
||||
representativeMime: isHostile ? (BROWSER_HOSTILE_CODECS[codecName] ?? null) : null,
|
||||
hasAlpha,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
filePath: string,
|
||||
runner?: FfprobeRunner,
|
||||
): Promise<AssetCodecFacts | null> {
|
||||
const metadata = runner
|
||||
? await probeMediaMetadata(filePath, runner)
|
||||
: await probeMediaMetadata(filePath);
|
||||
if (metadata.kind !== "video" || metadata.probeError) return null;
|
||||
const codecName = metadata.color.codecName;
|
||||
if (!codecName) return null;
|
||||
return codecFactsFor(codecName, pixelFormatHasAlpha(metadata.color.pixelFormat));
|
||||
}
|
||||
|
||||
interface CachedAssetProbe {
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
facts: AssetCodecFacts | null;
|
||||
}
|
||||
|
||||
/** Per (path, mtime) probe cache. Construct one per project/server lifetime
|
||||
* and reuse it across scans; a fresh instance defeats the caching benefit. */
|
||||
export type MediaCodecProbeCache = Map<string, CachedAssetProbe>;
|
||||
|
||||
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`.
|
||||
const defaultProbeCache: MediaCodecProbeCache = new Map();
|
||||
const MAX_PROBE_CACHE_ENTRIES = 512;
|
||||
|
||||
function rememberProbeResult(
|
||||
cache: MediaCodecProbeCache,
|
||||
filePath: string,
|
||||
result: CachedAssetProbe,
|
||||
): void {
|
||||
if (!cache.has(filePath) && cache.size >= MAX_PROBE_CACHE_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest) cache.delete(oldest);
|
||||
}
|
||||
// Refresh insertion order so frequently used assets remain resident.
|
||||
cache.delete(filePath);
|
||||
cache.set(filePath, result);
|
||||
}
|
||||
|
||||
async function probeAssetCodecCached(
|
||||
filePath: string,
|
||||
cache: MediaCodecProbeCache,
|
||||
runner?: FfprobeRunner,
|
||||
): Promise<AssetCodecFacts | null> {
|
||||
let stat: ReturnType<typeof statSync>;
|
||||
try {
|
||||
stat = statSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const cached = cache.get(filePath);
|
||||
if (cached && 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 });
|
||||
return facts;
|
||||
}
|
||||
|
||||
/** Structurally compatible with `packages/lint/src/hevcPreviewLint.ts`'s
|
||||
* (unexported) `HtmlSourceLike`. */
|
||||
export interface HtmlSourceLike {
|
||||
html: string;
|
||||
compSrcPath?: string;
|
||||
}
|
||||
|
||||
// --- <video src> collection: shared primitives live in
|
||||
// @hyperframes/parsers/asset-resolution; the <video>-specific regex and the
|
||||
// pinned key derivation stay here.
|
||||
const VIDEO_SRC_RE = /<video\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
/**
|
||||
* Resolve a `<video src>` reference to an existing local file.
|
||||
*
|
||||
* `rootRelativePathname` is the map key format PINNED by this plan's Key
|
||||
* Technical Decisions: project-root-relative URL pathname, percent-decoded,
|
||||
* query-string-stripped, forward-slash separated, leading-slash prefixed
|
||||
* (e.g. "/assets/videos/clip.mp4"). This must match what the runtime derives
|
||||
* via `new URL(el.currentSrc || el.src, document.baseURI).pathname`, because
|
||||
* server-side scanning resolves filesystem paths while the DOM sees served
|
||||
* URLs — a documented prior source of this exact class of bug.
|
||||
*/
|
||||
function resolveExistingLocalAsset(
|
||||
projectDir: string,
|
||||
url: string,
|
||||
): { resolvedPath: string; rootRelativePathname: string } | null {
|
||||
const projectRoot = resolve(projectDir);
|
||||
const resolvedPath = resolveLocalAssetCandidates(projectRoot, url).find((candidate) =>
|
||||
existsSync(candidate),
|
||||
);
|
||||
if (!resolvedPath) return null;
|
||||
const rootRelative = relative(projectRoot, resolvedPath).split(sep).join("/");
|
||||
return { resolvedPath, rootRelativePathname: `/${rootRelative}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects local `<video src>` references, resolved to their absolute path
|
||||
* and deduped by that path, keyed by the pinned root-relative URL pathname.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function collectLocalVideoAssets(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSourceLike[],
|
||||
): Map<string, string> {
|
||||
const candidates = new Map<string, string>();
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
const re = new RegExp(VIDEO_SRC_RE.source, VIDEO_SRC_RE.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(scannable)) !== null) {
|
||||
const src = cleanAssetUrl(match[1] ?? "");
|
||||
if (!src || isRemoteOrInlineUrl(src)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(src)) continue;
|
||||
const rootRelativeSrc = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||
const resolved = resolveExistingLocalAsset(projectDir, rootRelativeSrc);
|
||||
if (!resolved) continue;
|
||||
candidates.set(resolved.resolvedPath, resolved.rootRelativePathname);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// Bounds concurrent ffprobe child processes for projects referencing many
|
||||
// videos, mirroring `PROBE_CONCURRENCY` in `hevcPreviewLint.ts`.
|
||||
const PROBE_CONCURRENCY = 8;
|
||||
|
||||
export interface ScanProjectMediaCodecMapOptions {
|
||||
/** Persisted across calls by the caller for the mtime-cache benefit;
|
||||
* defaults to a shared module-level cache when omitted. */
|
||||
cache?: MediaCodecProbeCache;
|
||||
runner?: FfprobeRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a project's composition HTML for local `<video src>` references and
|
||||
* returns the injection map: root-relative URL pathname -> codec facts.
|
||||
* Best-effort throughout — a video whose codec can't be determined (missing
|
||||
* ffprobe, probe error, no video stream) is simply omitted, never thrown.
|
||||
*/
|
||||
export async function scanProjectMediaCodecMap(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSourceLike[],
|
||||
options: ScanProjectMediaCodecMapOptions = {},
|
||||
): Promise<MediaCodecMap> {
|
||||
const candidates = collectLocalVideoAssets(projectDir, htmlSources);
|
||||
if (candidates.size === 0) return {};
|
||||
|
||||
const cache = options.cache ?? defaultProbeCache;
|
||||
const entries = [...candidates.entries()]; // [resolvedPath, rootRelativePathname]
|
||||
const facts = new Array<AssetCodecFacts | null>(entries.length).fill(null);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(PROBE_CONCURRENCY, entries.length);
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < entries.length) {
|
||||
const index = nextIndex++;
|
||||
const entry = entries[index];
|
||||
if (!entry) break;
|
||||
facts[index] = await probeAssetCodecCached(entry[0], cache, options.runner);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const map: MediaCodecMap = {};
|
||||
entries.forEach(([, pathname], index) => {
|
||||
const entryFacts = facts[index];
|
||||
if (entryFacts?.browserHostile) map[pathname] = entryFacts;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyMediaColor, probeMediaMetadata } from "./mediaMetadata.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { classifyMediaColor, pixelFormatHasAlpha, probeMediaMetadata } from "./mediaMetadata.js";
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("classifyMediaColor", () => {
|
||||
it("detects HDR PQ from BT.2020 + smpte2084 metadata", () => {
|
||||
@@ -55,8 +57,8 @@ describe("classifyMediaColor", () => {
|
||||
});
|
||||
|
||||
describe("probeMediaMetadata", () => {
|
||||
it("reads the first video stream from ffprobe JSON", () => {
|
||||
const metadata = probeMediaMetadata("/tmp/clip.mp4", () => ({
|
||||
it("reads the first video stream from ffprobe JSON", async () => {
|
||||
const metadata = await probeMediaMetadata("/tmp/clip.mp4", () => ({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({
|
||||
streams: [
|
||||
@@ -80,18 +82,78 @@ describe("probeMediaMetadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unknown metadata when ffprobe is unavailable", () => {
|
||||
expect(
|
||||
it("ignores attached cover art and reads the real video stream", async () => {
|
||||
const metadata = await probeMediaMetadata("/tmp/clip.mp4", () => ({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "mjpeg",
|
||||
pix_fmt: "yuvj420p",
|
||||
disposition: { attached_pic: 1 },
|
||||
},
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "hevc",
|
||||
pix_fmt: "yuv420p10le",
|
||||
disposition: { attached_pic: 0 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
kind: "video",
|
||||
color: { codecName: "hevc", pixelFormat: "yuv420p10le" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unknown metadata when ffprobe is unavailable", async () => {
|
||||
await expect(
|
||||
probeMediaMetadata("/tmp/clip.mp4", () => ({
|
||||
status: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: { code: "ENOENT" } as NodeJS.ErrnoException,
|
||||
})),
|
||||
).toMatchObject({
|
||||
).resolves.toMatchObject({
|
||||
kind: "video",
|
||||
color: { dynamicRange: "unknown", isHdr: false },
|
||||
probeError: "ffprobe unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports an injected async runner without requiring local ffprobe", async () => {
|
||||
vi.stubEnv("HYPERFRAMES_FFPROBE_PATH", "/definitely/missing/ffprobe");
|
||||
const metadata = await probeMediaMetadata("/tmp/clip.mp4", async () => ({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({
|
||||
streams: [{ codec_type: "video", codec_name: "h264", pix_fmt: "yuv420p" }],
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
expect(metadata).toMatchObject({ kind: "video", color: { codecName: "h264" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pixelFormatHasAlpha", () => {
|
||||
it("detects alpha-bearing pixel formats", () => {
|
||||
for (const pixFmt of [
|
||||
"yuva420p",
|
||||
"yuva444p10le",
|
||||
"rgba",
|
||||
"argb",
|
||||
"bgra",
|
||||
"abgr",
|
||||
"gbrap12le",
|
||||
"ya8",
|
||||
]) {
|
||||
expect(pixelFormatHasAlpha(pixFmt)).toBe(true);
|
||||
}
|
||||
for (const pixFmt of ["yuv420p", "yuv422p10le", "rgb24", "gbrp", "gray", undefined]) {
|
||||
expect(pixelFormatHasAlpha(pixFmt)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,50 @@
|
||||
import { spawnSync, type SpawnSyncOptions } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { extname } from "node:path";
|
||||
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
||||
|
||||
type FfprobeRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnSyncOptions,
|
||||
) => {
|
||||
export interface FfprobeRunResult {
|
||||
status: number | null;
|
||||
stdout: string | Buffer;
|
||||
stderr: string | Buffer;
|
||||
error?: NodeJS.ErrnoException;
|
||||
};
|
||||
/** Spawn-level failure (covers both `NodeJS.ErrnoException` and
|
||||
* `ExecFileException`); only `code === "ENOENT"` is ever inspected. */
|
||||
error?: { code?: string | number | null | undefined };
|
||||
}
|
||||
|
||||
/** Injectable ffprobe runner. May be synchronous (tests) or async (the
|
||||
* default `execFile`-based runner below), so cold scans can run many probes
|
||||
* concurrently off the event loop. */
|
||||
export type FfprobeRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { timeout?: number; maxBuffer?: number },
|
||||
) => FfprobeRunResult | Promise<FfprobeRunResult>;
|
||||
|
||||
/** Default runner: genuinely async (`execFile`), unlike the previous
|
||||
* `spawnSync`-based one — a pool of concurrent probes actually parallelizes
|
||||
* (mirrors `execFileAsync` in packages/lint/src/hevcPreviewLint.ts). */
|
||||
const execFileRunner: FfprobeRunner = (command, args, options) =>
|
||||
new Promise<FfprobeRunResult>((resolvePromise) => {
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{ timeout: options?.timeout, maxBuffer: options?.maxBuffer },
|
||||
(error, stdout, stderr) => {
|
||||
if (error && error.code === "ENOENT") {
|
||||
resolvePromise({ status: null, stdout: "", stderr: "", error });
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
// Nonzero exit / timeout / kill: report a nonzero status; callers
|
||||
// only distinguish "ok" (0) from "failed" from "ENOENT".
|
||||
const status = typeof error.code === "number" ? error.code : 1;
|
||||
resolvePromise({ status, stdout: stdout ?? "", stderr: stderr ?? "" });
|
||||
return;
|
||||
}
|
||||
resolvePromise({ status: 0, stdout: stdout ?? "", stderr: stderr ?? "" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
export type MediaDynamicRange = "hdr" | "sdr" | "unknown";
|
||||
export type MediaHdrTransfer = "pq" | "hlg" | "unknown";
|
||||
@@ -44,9 +78,21 @@ interface FfprobeStream {
|
||||
color_transfer?: string;
|
||||
color_primaries?: string;
|
||||
bits_per_raw_sample?: string;
|
||||
disposition?: { attached_pic?: number };
|
||||
}
|
||||
|
||||
const VIDEO_EXT = new Set([".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"]);
|
||||
const VIDEO_EXT = new Set([
|
||||
".mp4",
|
||||
".mov",
|
||||
".webm",
|
||||
".mkv",
|
||||
".avi",
|
||||
".m4v",
|
||||
".mxf",
|
||||
".mts",
|
||||
".m2ts",
|
||||
".ts",
|
||||
]);
|
||||
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
|
||||
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
|
||||
|
||||
@@ -84,6 +130,17 @@ function colorLabel(input: {
|
||||
return "SDR/unknown";
|
||||
}
|
||||
|
||||
// Conservative alpha-bearing pix_fmt list: yuva* (yuva420p, yuva444p10le...),
|
||||
// rgba/argb/bgra/abgr (packed RGB+alpha), gbrap* (planar GBR+alpha, ProRes
|
||||
// 4444 decodes to these), ya* (gray+alpha). Prefix match keeps bit-depth /
|
||||
// endianness suffixes covered.
|
||||
const ALPHA_PIX_FMT_RE = /^(?:yuva|rgba|argb|bgra|abgr|gbrap|ya)/;
|
||||
|
||||
/** True when an ffprobe `pix_fmt` carries an alpha component. */
|
||||
export function pixelFormatHasAlpha(pixFmt: string | undefined): boolean {
|
||||
return pixFmt !== undefined && ALPHA_PIX_FMT_RE.test(pixFmt.toLowerCase());
|
||||
}
|
||||
|
||||
export function classifyMediaColor(stream: FfprobeStream | null | undefined): MediaColorMetadata {
|
||||
const colorPrimaries = lower(stream?.color_primaries);
|
||||
const colorSpace = lower(stream?.color_space);
|
||||
@@ -118,22 +175,31 @@ export function classifyMediaColor(stream: FfprobeStream | null | undefined): Me
|
||||
};
|
||||
}
|
||||
|
||||
export function probeMediaMetadata(
|
||||
export async function probeMediaMetadata(
|
||||
filePath: string,
|
||||
runner: FfprobeRunner = spawnSync as unknown as FfprobeRunner,
|
||||
): MediaMetadata {
|
||||
runner: FfprobeRunner = execFileRunner,
|
||||
): Promise<MediaMetadata> {
|
||||
const kind = inferKindFromPath(filePath);
|
||||
if (kind === "audio" || kind === "unknown") {
|
||||
return { kind, color: classifyMediaColor(null) };
|
||||
}
|
||||
|
||||
const result = runner(
|
||||
"ffprobe",
|
||||
// The default runner degrades a missing ffprobe to "unavailable" without
|
||||
// spawning; injected runners own execution and receive the normal command.
|
||||
const ffprobePath =
|
||||
findFfBinary("ffprobe", { configuredMustExist: true }) ??
|
||||
(runner === execFileRunner ? undefined : "ffprobe");
|
||||
if (!ffprobePath) {
|
||||
return { kind, color: classifyMediaColor(null), probeError: "ffprobe unavailable" };
|
||||
}
|
||||
|
||||
const result = await runner(
|
||||
ffprobePath,
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample",
|
||||
"stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample:stream_disposition=attached_pic",
|
||||
"-of",
|
||||
"json",
|
||||
filePath,
|
||||
@@ -150,9 +216,10 @@ export function probeMediaMetadata(
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(result.stdout || "{}")) as { streams?: FfprobeStream[] };
|
||||
const stream = parsed.streams?.find((item) =>
|
||||
kind === "image" ? item.codec_type === "video" : item.codec_type === kind,
|
||||
);
|
||||
const stream = parsed.streams?.find((item) => {
|
||||
if (kind === "image") return item.codec_type === "video";
|
||||
return item.codec_type === kind && item.disposition?.attached_pic !== 1;
|
||||
});
|
||||
return { kind, color: classifyMediaColor(stream) };
|
||||
} catch {
|
||||
return { kind, color: classifyMediaColor(null), probeError: "ffprobe returned invalid json" };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
||||
const VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
|
||||
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
||||
|
||||
type FfprobeRunner = (
|
||||
|
||||
Reference in New Issue
Block a user