Files
hyperframes/packages/engine/src/utils/ffprobe.ts
T
James Russo 9792c32950 fix(producer): reject asset media type mismatches (#2937)
* fix(producer): reject asset media type mismatches

* fix(engine): document read-only AVIF probe

* fix(engine): bound read-only AVIF brand probe

* fix(producer): make media preflight lifecycle-safe

* fix(producer): reconcile runtime media before preflight

* fix(engine): avoid writable file-open detection

* fix(producer): close runtime media preflight gaps
2026-08-03 18:16:41 -07:00

1058 lines
40 KiB
TypeScript

// fallow-ignore-file code-duplication complexity
import { spawn } from "child_process";
import { createReadStream, readFileSync, statSync } from "fs";
import * as zlib from "node:zlib";
import { StringDecoder } from "node:string_decoder";
import { basename } from "path";
import { redactTelemetryString } from "@hyperframes/core";
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
import { ManagedChildProcess } from "./managedChildProcess.js";
import { trackChildProcess } from "./processTracker.js";
const FFPROBE_STDERR_MAX_BYTES = 8 * 1024;
/** Bound on collected stdout. Generous — real -show_streams JSON is well
* under this — but finite, unlike the previous unbounded accumulation. */
const FFPROBE_STDOUT_MAX_CHARS = 8_000_000;
const FFPROBE_ERROR_MAX_CHARS = 4 * 1024;
function redactFfprobeInput(stderr: string, filePath: string): string {
if (!filePath) return stderr;
let redacted = stderr.split(filePath).join("[input]");
const inputBasename = basename(filePath);
if (inputBasename) redacted = redacted.split(inputBasename).join("[input]");
// ManagedChildProcess retains an 8 KiB byte tail. When that boundary lands
// inside the input path, stderr starts with only a suffix of the path, so
// neither exact-path nor generic absolute-path redaction can recognize it.
if (!redacted.startsWith("[input]")) {
for (let offset = 1; offset < filePath.length; offset += 1) {
const suffix = filePath.slice(offset);
if (suffix.length < 4 || !redacted.startsWith(suffix)) continue;
const boundary = redacted[suffix.length];
if (boundary !== undefined && !/[\s:'")\]}]/.test(boundary)) continue;
redacted = `[input]${redacted.slice(suffix.length)}`;
break;
}
}
return redacted;
}
function sanitizeFfprobeDiagnostic(stderr: string, filePath: string): string {
const stderrWithoutInput = redactFfprobeInput(stderr, filePath);
const redacted = redactTelemetryString(stderrWithoutInput, FFPROBE_STDERR_MAX_BYTES);
if (redacted.length <= FFPROBE_ERROR_MAX_CHARS) return redacted;
return `…${redacted.slice(-(FFPROBE_ERROR_MAX_CHARS - 1))}`;
}
/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
async function runFfprobe(
filePath: string,
argsWithoutInput: string[],
signal?: AbortSignal,
stdoutOptions?: { retainTail?: boolean; maxChars?: number },
): Promise<string> {
// `--` stops option parsing so a path like "-intro.mp4" is a filename, but
// it does NOT cover a path of exactly "-": ffprobe rewrites that to `fd:`
// AFTER option parsing and then reads stdin. Since stdin here is a pipe the
// parent never writes to and never ends, the probe hangs for the full 30s
// deadline and fails with an empty diagnostic (ffprobe never errored, so
// stderr is blank). Reject it up front with something a caller can read.
if (filePath === "-") {
throw new Error('[FFmpeg] Refusing to probe "-": stdin is not a supported input path.');
}
const command = getFfprobeBinary();
const proc = spawn(command, ["-v", "error", ...argsWithoutInput, "--", filePath], {
// Nothing is ever written to the child's stdin; leaving it as a pipe is
// what lets a stdin-reading invocation block indefinitely.
stdio: ["ignore", "pipe", "pipe"],
});
trackChildProcess(proc);
// Decoded through StringDecoder rather than per-chunk toString(): a
// multi-byte character split across a 64 KiB pipe boundary decodes to U+FFFD
// on both sides. -show_format output above ~64 KiB with non-ASCII tag text
// (an MKV with many chapters, or title/artist tags) came back silently
// mangled — JSON.parse still succeeds, so nothing surfaced it, and tag
// lookups like alpha_mode could miss.
const decoder = new StringDecoder("utf8");
let stdout = "";
let stdoutTruncated = false;
const stdoutMaxChars = stdoutOptions?.maxChars ?? FFPROBE_STDOUT_MAX_CHARS;
proc.stdout.on("data", (data: Buffer) => {
// stderr is capped by ManagedChildProcess; stdout had no bound at all, and
// analyzeKeyframeIntervals emits one line per frame — an all-intra ProRes
// proxy can produce an unbounded string.
if (stdoutTruncated) return;
stdout += decoder.write(data);
// Checked AFTER appending: a single chunk can already exceed the bound,
// so a pre-append check only ever stops the second one.
if (stdout.length > stdoutMaxChars) {
if (stdoutOptions?.retainTail) {
stdout = stdout.slice(-stdoutMaxChars);
} else {
stdoutTruncated = true;
stdout = "";
}
}
});
const managed = new ManagedChildProcess(proc, {
signal,
deadlineAtMs: Date.now() + 30_000,
stderrMaxBytes: FFPROBE_STDERR_MAX_BYTES,
});
const outcome = await managed.wait();
stdout += decoder.end();
if (stdoutTruncated) {
throw new Error(
`[FFmpeg] ffprobe output exceeded ${stdoutMaxChars} characters; refusing to parse a truncated result.`,
);
}
if (outcome.reason === "spawn_error") {
if ((outcome.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
throw new Error(
configured
? `[FFmpeg] ffprobe not found at ${FFPROBE_PATH_ENV}="${configured}". Please install FFmpeg.`
: "[FFmpeg] ffprobe not found. Please install FFmpeg.",
);
}
throw outcome.error ?? new Error(outcome.stderr);
}
if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
const diagnostic = sanitizeFfprobeDiagnostic(outcome.stderr, filePath);
throw new Error(
`[FFmpeg] ffprobe ${outcome.reason} with code ${outcome.exitCode}: ${diagnostic}`,
);
}
return stdout;
}
function parseProbeJson(stdout: string): FFProbeOutput {
try {
return JSON.parse(stdout);
} catch (e) {
throw new Error(
`[FFmpeg] Failed to parse ffprobe output: ${e instanceof Error ? e.message : e}`,
);
}
}
const videoMetadataCache = new Map<string, Promise<VideoMetadata>>();
const finalVideoFrameTimestampCache = new Map<string, Promise<number>>();
const finalVideoFrameTimestampSignalCaches = new WeakMap<
AbortSignal,
Map<string, Promise<number>>
>();
const audioMetadataCache = new Map<string, Promise<AudioMetadata>>();
interface MediaProbeCacheEntry {
identity: string;
promise: Promise<FFProbeOutput>;
}
const mediaProbeOutputCache = new Map<string, MediaProbeCacheEntry>();
const mediaProbeOutputSignalCaches = new WeakMap<AbortSignal, Map<string, MediaProbeCacheEntry>>();
const MEDIA_PROBE_OUTPUT_CACHE_MAX_ENTRIES = 128;
// FFmpeg's built-in AAC encoder emits AAC-LC, which has 1024 samples per packet.
const AAC_LC_SAMPLES_PER_PACKET = 1024;
export interface VideoColorSpace {
/** Color transfer characteristics, e.g. "bt709", "smpte2084", "arib-std-b67" */
colorTransfer: string;
/** Color primaries, e.g. "bt709", "bt2020" */
colorPrimaries: string;
/** Color matrix/space, e.g. "bt709", "bt2020nc" */
colorSpace: string;
}
export interface VideoMetadata {
durationSeconds: number;
videoStreamDurationSeconds: number;
/** Absolute presentation timestamp at which the selected video stream
* starts. FFmpeg input seeks are relative to this point, while ffprobe frame
* timestamps are absolute, so callers crossing those APIs must normalize by
* this value. Absent only in legacy/manually-constructed metadata. */
videoStreamStartSeconds?: number;
width: number;
height: number;
fps: number;
videoCodec: string;
hasAudio: boolean;
/** True when r_frame_rate and avg_frame_rate differ significantly (>10%), indicating variable frame rate. */
isVFR: boolean;
/** True when the stream carries an alpha channel. */
hasAlpha: boolean;
/** Color space info from the video stream. Null if ffprobe didn't report it. */
colorSpace: VideoColorSpace | null;
}
export interface AudioMetadata {
durationSeconds: number;
/** Audio stream's own duration (from `stream.duration`), falling back to
* container duration when the stream field is absent. Prefer this over
* `durationSeconds` for stream-level parity checks. */
streamDurationSeconds?: number;
sampleRate: number;
channels: number;
audioCodec: string;
bitrate?: number;
}
interface FFProbeStream {
codec_type: string;
codec_name?: string;
/** e.g. "LC", "HE-AAC", "HE-AACv2" — where the SBR marker lives, since
* codec_name is plain "aac" for all of them. */
profile?: string;
width?: number;
height?: number;
duration?: string;
start_time?: string;
nb_frames?: string;
nb_read_packets?: string;
pix_fmt?: string;
r_frame_rate?: string;
avg_frame_rate?: string;
sample_rate?: string;
channels?: number;
color_transfer?: string;
color_primaries?: string;
color_space?: string;
tags?: Record<string, string>;
disposition?: { attached_pic?: number };
}
interface FFProbeFormat {
duration?: string;
bit_rate?: string;
format_name?: string;
}
interface FFProbeOutput {
streams: FFProbeStream[];
format: FFProbeFormat;
}
interface StillImageMetadata {
width: number;
height: number;
colorSpace: VideoColorSpace | null;
}
export interface MediaProbeProfile {
hasVideoStream: boolean;
hasAudioStream: boolean;
visualKind: "none" | "still" | "moving";
}
const STILL_IMAGE_DEMUXERS = new Set([
"apng",
"bmp_pipe",
"dds_pipe",
"dpx_pipe",
"exr_pipe",
"gif",
"ico",
"image2",
"image2pipe",
"jpeg_pipe",
"jxl_pipe",
"png_pipe",
"qdraw_pipe",
"sgi_pipe",
"svg_pipe",
"tiff_pipe",
"webp_pipe",
]);
async function hasAvifFileBrand(filePath: string): Promise<boolean> {
try {
const chunks: Buffer[] = [];
for await (const chunk of createReadStream(filePath, { start: 0, end: 4095 })) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const bytes = Buffer.concat(chunks);
if (bytes.length < 16 || bytes.toString("ascii", 4, 8) !== "ftyp") return false;
const boxSize = bytes.readUInt32BE(0);
if (boxSize < 16 || boxSize > bytes.length) return false;
const avifBrands = new Set(["avif", "avis"]);
if (avifBrands.has(bytes.toString("ascii", 8, 12))) return true;
for (let offset = 16; offset + 4 <= boxSize; offset += 4) {
if (avifBrands.has(bytes.toString("ascii", offset, offset + 4))) return true;
}
return false;
} catch {
return false;
}
}
async function isStillImageVisual(output: FFProbeOutput, filePath: string): Promise<boolean> {
const formatNames = (output.format.format_name ?? "")
.split(",")
.map((name) => name.trim().toLowerCase())
.filter(Boolean);
return (
formatNames.some((name) => STILL_IMAGE_DEMUXERS.has(name)) || (await hasAvifFileBrand(filePath))
);
}
function isPngImageProbe(output: FFProbeOutput): boolean {
const formatNames = (output.format.format_name ?? "")
.split(",")
.map((name) => name.trim().toLowerCase());
return (
formatNames.includes("png_pipe") ||
output.streams.some(
(stream) => stream.codec_type === "video" && stream.codec_name?.toLowerCase() === "png",
)
);
}
function mediaFileIdentity(filePath: string): string | null {
try {
const stat = statSync(filePath, { bigint: true });
return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
} catch {
return null;
}
}
async function probeMediaOutput(filePath: string, signal?: AbortSignal): Promise<FFProbeOutput> {
let cache = mediaProbeOutputCache;
if (signal) {
cache = mediaProbeOutputSignalCaches.get(signal) ?? new Map<string, MediaProbeCacheEntry>();
mediaProbeOutputSignalCaches.set(signal, cache);
}
const identity = mediaFileIdentity(filePath);
const cached = cache.get(filePath);
if (identity !== null && cached?.identity === identity) {
// The no-signal fallback is process-scoped, so touch its entries to make
// the fixed-size map an LRU. Signal-owned maps are released with their
// render and do not need process-lifetime eviction.
if (!signal) {
cache.delete(filePath);
cache.set(filePath, cached);
}
return cached.promise;
}
const promise = runFfprobe(
filePath,
["-print_format", "json", "-show_format", "-show_streams"],
signal,
).then(parseProbeJson);
if (identity !== null) {
cache.set(filePath, { identity, promise });
if (!signal && cache.size > MEDIA_PROBE_OUTPUT_CACHE_MAX_ENTRIES) {
const oldestPath = cache.keys().next().value;
if (oldestPath !== undefined) cache.delete(oldestPath);
}
}
promise.catch(() => {
if (cache.get(filePath)?.promise === promise) {
cache.delete(filePath);
}
});
return promise;
}
class StructurallyIncompletePngError extends Error {}
async function readFileRange(
filePath: string,
start: number,
length: number,
signal?: AbortSignal,
): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of createReadStream(filePath, {
start,
end: start + length - 1,
highWaterMark: length,
signal,
})) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function hasCompletePngStructure(filePath: string, signal?: AbortSignal): Promise<boolean> {
try {
signal?.throwIfAborted();
const fileSize = statSync(filePath).size;
// Range streams are read-only and skip large IDAT payloads without loading
// the entire image into memory.
const signature = await readFileRange(filePath, 0, 8, signal);
signal?.throwIfAborted();
if (
signature.length !== 8 ||
!signature.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
) {
return false;
}
let seenHeader = false;
let seenImageData = false;
let offset = 8;
while (offset + 12 <= fileSize) {
signal?.throwIfAborted();
const chunkHeader = await readFileRange(filePath, offset, 8, signal);
signal?.throwIfAborted();
if (chunkHeader.length !== 8) return false;
const chunkLength = chunkHeader.readUInt32BE(0);
const chunkEnd = offset + 12 + chunkLength;
if (chunkEnd > fileSize) return false;
const chunkType = chunkHeader.toString("ascii", 4, 8);
if (chunkType === "IHDR") seenHeader = chunkLength === 13 && offset === 8;
if (chunkType === "IDAT") seenImageData = true;
if (chunkType === "IEND") return seenHeader && seenImageData && chunkLength === 0;
offset = chunkEnd;
}
return false;
} catch (error) {
if (signal?.aborted) throw signal.reason ?? error;
return false;
}
}
/**
* Probe stream capabilities without assuming the caller's element type.
* File extensions and HTTP MIME are deliberately ignored: extensionless
* assets and valid media served through generic CDN content types must work.
*/
export async function probeMediaProfile(
filePath: string,
options?: { signal?: AbortSignal },
): Promise<MediaProbeProfile> {
try {
const output = await probeMediaOutput(filePath, options?.signal);
options?.signal?.throwIfAborted();
const videoStreams = output.streams.filter((stream) => stream.codec_type === "video");
const hasMovingVideoStream = videoStreams.some(
(stream) => stream.disposition?.attached_pic !== 1,
);
const isStillImage = videoStreams.length > 0 && (await isStillImageVisual(output, filePath));
options?.signal?.throwIfAborted();
if (
isStillImage &&
isPngImageProbe(output) &&
!(await hasCompletePngStructure(filePath, options?.signal))
) {
throw new StructurallyIncompletePngError("[FFmpeg] PNG input is structurally incomplete");
}
return {
hasVideoStream: videoStreams.length > 0,
hasAudioStream: output.streams.some((stream) => stream.codec_type === "audio"),
visualKind: isStillImage ? "still" : hasMovingVideoStream ? "moving" : "none",
};
} catch (error) {
if (options?.signal?.aborted) throw options.signal.reason ?? error;
if (error instanceof StructurallyIncompletePngError) throw error;
// Preserve the PNG parser fallback used by extractMediaMetadata when the
// packaged ffprobe binary is unavailable. Signature parsing (not the file
// extension) keeps extensionless PNGs eligible for preflight.
const stillImage = extractStillImageMetadata(filePath);
if (stillImage && (await hasCompletePngStructure(filePath, options?.signal))) {
return { hasVideoStream: true, hasAudioStream: false, visualKind: "still" };
}
throw error;
}
}
// node:zlib's crc32 is native and takes a running seed, so the chunk type and
// the chunk data can be CRC'd in sequence without concatenating them into a
// throwaway buffer: ~210 ms -> ~1.3 ms on a 12 MiB PNG.
//
// It landed in Node 22.2.0, and this repo declares `"node": ">=22"` with a
// major-only runtime gate, so 22.0 and 22.1 are still supported. A NAMED
// import of a missing export throws at module evaluation — i.e. `ffprobe.ts`
// would fail to load at all on those, long before any PNG is parsed — so the
// namespace import plus this capability check is deliberate. Raising the
// floor to 22.2.0 instead would be a user-facing support change, which does
// not belong in a PNG bug fix.
const nativeCrc32 = typeof zlib.crc32 === "function" ? zlib.crc32 : undefined;
/** Bit-at-a-time fallback for Node 22.0/22.1. Correct, just slower. */
function crc32Fallback(data: Buffer, seed: number): number {
let crc = seed ^ 0xffffffff;
for (let i = 0; i < data.length; i++) {
crc ^= data[i] ?? 0;
for (let bit = 0; bit < 8; bit++) {
const mask = -(crc & 1);
crc = (crc >>> 1) ^ (0xedb88320 & mask);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function chunkCrc32(chunkType: string, chunkData: Buffer): number {
const typeBytes = Buffer.from(chunkType, "ascii");
if (nativeCrc32) return nativeCrc32(chunkData, nativeCrc32(typeBytes));
return crc32Fallback(chunkData, crc32Fallback(typeBytes, 0));
}
export function extractPngMetadataFromBuffer(buf: Buffer): StillImageMetadata | null {
if (
buf.length < 8 ||
buf[0] !== 137 ||
buf[1] !== 80 ||
buf[2] !== 78 ||
buf[3] !== 71 ||
buf[4] !== 13 ||
buf[5] !== 10 ||
buf[6] !== 26 ||
buf[7] !== 10
) {
return null;
}
let width = 0;
let height = 0;
let seenIdat = false;
let colorSpaceFromCicp: VideoColorSpace | null = null;
let pos = 8;
while (pos + 12 <= buf.length) {
const chunkLen = buf.readUInt32BE(pos);
const chunkType = buf.toString("ascii", pos + 4, pos + 8);
if (pos + 12 + chunkLen > buf.length) return null;
const chunkData = buf.subarray(pos + 8, pos + 8 + chunkLen);
const chunkCrc = buf.readUInt32BE(pos + 8 + chunkLen);
if (chunkCrc32(chunkType, chunkData) !== chunkCrc) return null;
// First IHDR only. PNG permits exactly one and it must come first, but a
// malformed file can carry more — without this anchor a trailing
// [IHDR 1x1] silently replaced the real 4K dimensions, and the producer
// laid out a one-pixel image. `>= 13` is the spec length; the old `>= 8`
// accepted a truncated header and read height out of the CRC bytes.
if (chunkType === "IHDR" && chunkLen >= 13 && width === 0 && height === 0) {
width = buf.readUInt32BE(pos + 8);
height = buf.readUInt32BE(pos + 12);
}
if (chunkType === "IDAT") {
seenIdat = true;
}
if (chunkType === "cICP" && chunkLen === 4 && !seenIdat) {
const primariesCode = chunkData[0] ?? 0;
const transferCode = chunkData[1] ?? 0;
const matrixCode = chunkData[2] ?? 0;
colorSpaceFromCicp = {
colorPrimaries:
primariesCode === 9
? "bt2020"
: primariesCode === 1
? "bt709"
: `unknown-${primariesCode}`,
colorTransfer:
transferCode === 16
? "smpte2084"
: transferCode === 18
? "arib-std-b67"
: transferCode === 1
? "bt709"
: `unknown-${transferCode}`,
colorSpace:
matrixCode === 9 ? "bt2020nc" : matrixCode === 0 ? "gbr" : `unknown-${matrixCode}`,
};
}
// Everything this parser extracts has been found, so stop walking.
//
// Not just an optimisation: cICP must precede IDAT (enforced above), so
// continuing only ever visits chunks we ignore — while making whole-file
// integrity a precondition for returning anything. A truncated or
// bad-CRC trailing chunk in an otherwise-good HDR PNG used to null the
// entire result, and the caller then re-throws the swallowed ffprobe
// error instead of using the fallback it just computed.
if (width > 0 && height > 0 && colorSpaceFromCicp !== null) break;
if (chunkType === "IEND") break;
pos += 12 + chunkLen;
}
return width > 0 && height > 0 ? { width, height, colorSpace: colorSpaceFromCicp } : null;
}
/**
* Does this pix_fmt carry an alpha channel?
*
* Exported so the test asserts the shipped predicate rather than a copy of
* the pattern. Anchored at the start, matching studio-server's
* mediaMetadata.ts: the previous inline pattern bound its `(^|[^a-z])` anchor
* to the first alternative only — `|` is looser than concatenation — so the
* guard was decorative for every other name. It also missed abgr, ya8/ya16
* and ayuv64, and its `gray[a-z0-9]*a` branch matched only gray8a/gray16a,
* names FFmpeg renamed to ya8/ya16 in 2013. A `ya8` grayscale-plus-alpha PNG
* reported hasAlpha:false, resolveFrameFormat picked jpg, and the overlay
* flattened to an opaque rectangle.
*/
export function pixelFormatHasAlpha(pixelFormat: string): boolean {
return /^(?:yuva|rgba|argb|bgra|abgr|gbrap|ya|ayuv)/i.test(pixelFormat);
}
function extractStillImageMetadata(filePath: string): StillImageMetadata | null {
try {
return extractPngMetadataFromBuffer(readFileSync(filePath));
} catch {
return null;
}
}
/**
* Read an ffprobe tag case-insensitively. ffmpeg/libavformat versions disagree
* on tag casing — VP9 alpha is `alpha_mode` in older builds and `ALPHA_MODE`
* in newer ones; HDR tags vary similarly. Use this for any sidecar tag where
* you want to be resilient across muxer versions.
*/
function readTagCI(tags: Record<string, string | undefined> | undefined, name: string): string {
if (!tags) return "";
const target = name.toLowerCase();
for (const [key, value] of Object.entries(tags)) {
if (key.toLowerCase() === target && typeof value === "string") return value;
}
return "";
}
/**
* Parse an ffprobe rational frame rate ("30000/1001") or plain number.
*
* Returns 0 for anything not a usable positive rate. Exported so tests
* exercise the shipped function directly instead of re-importing the module
* behind a spawn mock.
*
* Every guard here is load-bearing, because a bad value is NOT caught
* downstream: callers use `meta.fps || 30`, which only rescues 0 and NaN.
* Infinity and negatives are truthy and flow into buildEncoderArgs as
* `-r Infinity` / `-r -30`, which ffmpeg rejects mid-render, and into
* frameCount arithmetic that then goes negative or non-finite.
*
* - the QUOTIENT is checked, not just the operands: "1e308/1e-10" and
* "2/1e-320" have finite parts and an infinite result;
* - the sign is checked: "-30/1", "30/-1" and "-60" all parsed clean;
* - more than two parts is rejected: "30/1/2" used to fall through to the
* bare parseFloat below and return 30, as did "60fps", because parseFloat
* stops at trailing garbage;
* - sub-0.005 rates round to 0 at 2dp and would be replaced by the caller's
* 30fps default, re-encoding a 300-second 1/300-fps timelapse as a
* ~1/30-second clip. Kept as 0 is wrong too, so they are floored to the
* smallest representable 2dp rate instead.
*/
export function parseFrameRate(frameRateStr: string | undefined): number {
if (!frameRateStr) return 0;
const parts = frameRateStr.split("/");
if (parts.length > 2) return 0;
// Number(), never parseFloat — on BOTH the rational operands and the plain
// form. parseFloat stops at trailing garbage, so "60fps" parsed as 60 and,
// once the plain path was fixed but the operands were not, "60fps/1" and
// "30garbage/1garbage" still slipped through the rational branch.
const strict = (part: string | undefined): number =>
part === undefined || part.trim() === "" ? NaN : Number(part.trim());
const raw =
parts.length === 2
? (() => {
const num = strict(parts[0]);
const den = strict(parts[1]);
if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return NaN;
return num / den;
})()
: strict(frameRateStr);
if (!Number.isFinite(raw) || raw <= 0) return 0;
// Checked AFTER rounding as well as before. `raw * 100` overflows for a
// finite-but-huge rate ("1e307", "1e307/1"), so `rounded` became Infinity
// and sailed past the positivity check — reaching exactly the `-r Infinity`
// failure the finite guard above exists to prevent.
const rounded = Math.round(raw * 100) / 100;
if (!Number.isFinite(rounded)) return 0;
// A real but very slow rate must not collapse to 0 and inherit the
// caller's 30fps default.
return rounded > 0 ? rounded : 0.01;
}
/**
* Probe a media file (video, image, or container) and return normalized metadata.
*
* Despite the legacy name `extractVideoMetadata` (still exported as a
* deprecated alias below), this also handles still images such as PNG so it
* can be used uniformly for any visual asset the HDR pipeline encounters.
*/
export async function extractMediaMetadata(filePath: string): Promise<VideoMetadata> {
const cached = videoMetadataCache.get(filePath);
if (cached) return cached;
const probePromise = (async (): Promise<VideoMetadata> => {
// Lazily memoized. This is a pure fallback, but it used to run eagerly
// and synchronously BEFORE the first await: readFileSync plus a CRC walk
// per file, so a caller fanning out over composition.images with
// Promise.all executed every parse back-to-back before a single ffprobe
// was spawned (12 4K PNGs: 2649 ms vs 170 ms probe-only) — event-loop
// stall that also blocks Puppeteer IPC and progress reporting. On the
// happy path the result was then discarded.
let stillImageMetaMemo: StillImageMetadata | null | undefined;
const stillImage = (): StillImageMetadata | null => {
stillImageMetaMemo ??= extractStillImageMetadata(filePath);
return stillImageMetaMemo;
};
let output: FFProbeOutput | null = null;
try {
output = await probeMediaOutput(filePath);
} catch (error) {
if (!stillImage()) throw error;
}
const videoStream = output?.streams.find((s) => s.codec_type === "video");
if (!videoStream) {
const stillImageMeta = stillImage();
if (stillImageMeta) {
return {
durationSeconds: 0,
videoStreamDurationSeconds: 0,
videoStreamStartSeconds: 0,
width: stillImageMeta.width,
height: stillImageMeta.height,
fps: 0,
videoCodec: "png",
hasAudio: false,
isVFR: false,
hasAlpha: false,
colorSpace: stillImageMeta.colorSpace,
};
}
throw new Error("[FFmpeg] No video stream found");
}
const rFps = parseFrameRate(videoStream.r_frame_rate);
const avgFps = parseFrameRate(videoStream.avg_frame_rate);
const fps = avgFps || rFps;
// VFR: r_frame_rate (max/nominal) differs from avg_frame_rate (actual average) by >10%
const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
const colorTransfer = videoStream.color_transfer || "";
const colorPrimaries = videoStream.color_primaries || "";
const colorSpaceVal = videoStream.color_space || "";
// Merged per field, not whole-object. ffprobe emits color_space "gbr" for
// every PNG — even a plain rgb24 with no colour metadata — so a
// whole-object `??` meant the cICP fallback was discarded whenever
// ffprobe ran at all, which is exactly the build it exists to cover: one
// that reports gbr but does not decode cICP. An HDR PQ PNG then resolved
// colorTransfer "" and graded SDR.
const cicp = colorTransfer && colorPrimaries && colorSpaceVal ? null : stillImage()?.colorSpace;
const merged = {
colorTransfer: colorTransfer || cicp?.colorTransfer || "",
colorPrimaries: colorPrimaries || cicp?.colorPrimaries || "",
colorSpace: colorSpaceVal || cicp?.colorSpace || "",
};
const colorSpace =
merged.colorTransfer || merged.colorPrimaries || merged.colorSpace ? merged : null;
const pixelFormat = videoStream.pix_fmt || "";
const alphaMode = readTagCI(videoStream.tags, "alpha_mode");
const hasAlpha = pixelFormatHasAlpha(pixelFormat) || alphaMode === "1";
// Anchored at the start, matching studio-server's mediaMetadata.ts. The
// previous pattern bound its `(^|[^a-z])` anchor to the FIRST alternative
// only — `|` is looser than concatenation — so the guard was decorative
// for every other name. It also missed abgr, ya8/ya16 and ayuv64, and its
// `gray[a-z0-9]*a` branch matched only gray8a/gray16a, names FFmpeg
// renamed to ya8/ya16 in 2013. A `ya8` grayscale-plus-alpha PNG reported
// hasAlpha:false, resolveFrameFormat picked jpg, and the overlay
// flattened to an opaque rectangle.
const containerDuration = output?.format.duration ? parseFloat(output.format.duration) : 0;
const streamDuration = videoStream.duration ? parseFloat(videoStream.duration) : 0;
const parsedStreamStart = videoStream.start_time ? parseFloat(videoStream.start_time) : 0;
const streamStart = Number.isFinite(parsedStreamStart) ? parsedStreamStart : 0;
return {
durationSeconds: containerDuration,
videoStreamDurationSeconds: streamDuration > 0 ? streamDuration : containerDuration,
videoStreamStartSeconds: streamStart,
width: videoStream.width || stillImage()?.width || 0,
height: videoStream.height || stillImage()?.height || 0,
fps,
videoCodec: videoStream.codec_name || "unknown",
hasAudio: output?.streams.some((s) => s.codec_type === "audio") ?? false,
isVFR,
hasAlpha,
colorSpace,
};
})();
videoMetadataCache.set(filePath, probePromise);
probePromise.catch(() => {
if (videoMetadataCache.get(filePath) === probePromise) {
videoMetadataCache.delete(filePath);
}
});
return probePromise;
}
/**
* Return the FFmpeg input-seek position of the final decoded video frame.
*
* A fixed seek window near EOF is not sufficient: sub-1fps and sparse VFR
* sources can have no frame timestamp inside that window even though the last
* decoded frame remains displayed through the stream duration. ffprobe seeks
* to the preceding keyframe and walks forward; retaining only its stdout tail
* keeps memory bounded even for a pathological long GOP. ffprobe reports
* absolute presentation timestamps, but FFmpeg input `-ss` is relative to the
* stream start; the result is normalized into that relative seek domain. Some
* unindexed transports cannot decode after an interval seek, so an empty tail
* probe falls back to a bounded-output full scan rather than rejecting valid
* media. The scan may cost decode time, but retains only 64 KiB of timestamps.
*/
export async function extractFinalVideoFrameTimestamp(
filePath: string,
metadata: Pick<VideoMetadata, "videoStreamDurationSeconds" | "videoStreamStartSeconds">,
signal?: AbortSignal,
): Promise<number> {
const videoDurationSeconds = metadata.videoStreamDurationSeconds;
const candidateStreamStart = metadata.videoStreamStartSeconds ?? 0;
const videoStreamStartSeconds = Number.isFinite(candidateStreamStart) ? candidateStreamStart : 0;
const cacheKey = `${filePath}\0${String(videoStreamStartSeconds)}\0${String(videoDurationSeconds)}`;
// A caller-owned abort signal cannot safely own a globally shared process
// promise: aborting one render would fail unrelated consumers. Calls in the
// SAME cancellation scope should still share the expensive interval +
// fallback chain, though — duplicate held-tail elements in one render carry
// the same signal and otherwise fan out N full-file scans before extraction
// dedupe. Weakly key the cache by cancellation owner to preserve both
// aggregate work bounds and cross-render isolation.
let probeCache = finalVideoFrameTimestampCache;
if (signal) {
probeCache = finalVideoFrameTimestampSignalCaches.get(signal) ?? new Map();
finalVideoFrameTimestampSignalCaches.set(signal, probeCache);
}
const cached = probeCache.get(cacheKey);
if (cached) return cached;
const probePromise = (async () => {
if (!(videoDurationSeconds > 0) || !Number.isFinite(videoDurationSeconds)) {
throw new Error(
`[FFmpeg] Cannot locate final video frame for invalid duration ${String(videoDurationSeconds)}`,
);
}
const streamEnd = videoStreamStartSeconds + videoDurationSeconds;
const intervalStart = Math.max(videoStreamStartSeconds, streamEnd - 1);
const parseFinalTimestamp = (stdout: string): number | undefined =>
stdout
.split("\n")
.map((line) => line.trim().split(",")[0]?.trim() ?? "")
.filter((value) => value.length > 0)
.map((value) => Number(value))
.filter((timestamp) => Number.isFinite(timestamp))
.at(-1);
const probe = async (readInterval?: string): Promise<number | undefined> => {
const args = [
"-select_streams",
"v:0",
"-show_entries",
"frame=best_effort_timestamp_time",
"-of",
"csv=p=0",
];
if (readInterval) args.splice(2, 0, "-read_intervals", readInterval);
const stdout = await runFfprobe(filePath, args, signal, {
retainTail: true,
maxChars: 64 * 1024,
});
return parseFinalTimestamp(stdout);
};
const timestamp =
(await probe(`${intervalStart}%${streamEnd}`)) ?? (await probe(/* full scan */));
if (timestamp === undefined) {
throw new Error("[FFmpeg] ffprobe found no decodable final video frame");
}
return Math.min(Math.max(timestamp - videoStreamStartSeconds, 0), videoDurationSeconds);
})();
probeCache.set(cacheKey, probePromise);
probePromise.catch(() => {
if (probeCache.get(cacheKey) === probePromise) {
probeCache.delete(cacheKey);
}
});
return probePromise;
}
/**
* @deprecated Use `extractMediaMetadata` — this name is kept for backward
* compatibility with consumers that imported the original video-only name
* before still-image (PNG) support was added. New callers should prefer
* `extractMediaMetadata`.
*/
export const extractVideoMetadata = extractMediaMetadata;
export async function extractAudioMetadata(
filePath: string,
options?: { signal?: AbortSignal },
): Promise<AudioMetadata> {
// A caller-owned abort signal cannot safely share a cached in-flight probe:
// cancelling one consumer would also cancel unrelated consumers. Signal-bound
// probes therefore bypass the process-promise cache.
const cached = options?.signal ? undefined : audioMetadataCache.get(filePath);
if (cached) return cached;
const probePromise = (async (): Promise<AudioMetadata> => {
const output = await probeMediaOutput(filePath, options?.signal);
const audioStream = output.streams.find((s) => s.codec_type === "audio");
if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
const audioCodec = audioStream.codec_name || "unknown";
// AAC-LC container durations are often slightly wrong, so the packet
// count gives a better one. Three constraints on that refinement:
//
// 1. It must never fail the call. durationSeconds is ALREADY correct from
// format.duration at this point. `-count_packets` demuxes the whole
// container against runFfprobe's fixed 30s deadline, so a long file on
// slow or network storage times out — and the caller in htmlCompiler
// catches that under "Source file has no audio stream", returns
// duration 0, drops the audio element and ships a silent render.
// 2. It must honour the caller's AbortSignal. Only the first probe
// received it, so aborting during this one was ignored and the call
// resolved with full metadata long after cancellation.
// 3. It must apply ONLY to profiles whose 1024-sample framing is
// established. ffprobe reports codec_name "aac" for every AAC
// variant — the framing lives in the profile:
//
// LC 1024 samples/frame <- the only one this maths fits
// HE-AAC v1/v2 2048 output samples against a doubled sample_rate
// LD 512
// ELD 480
// Main/SSR/LTP 1024 nominally, but not verified here
// xHE-AAC (USAC) variable
//
// An ALLOWLIST, not a HE-AAC denylist. The denylist form let LD/ELD
// through (halving to a third of the true duration), and let an
// unknown or missing profile through too — so an unrecognised HE
// spelling preserved the exact truncation this is meant to close.
// A skipped refinement is harmless: format.duration is already correct.
const isAacLc = /^\s*LC\s*$/i.test(audioStream.profile ?? "");
if (audioCodec === "aac" && isAacLc && sampleRate > 0) {
try {
const packetStdout = await runFfprobe(
filePath,
[
"-select_streams",
"a:0",
"-count_packets",
"-show_entries",
"stream=nb_read_packets",
"-print_format",
"json",
],
options?.signal,
);
const packetOutput = parseProbeJson(packetStdout);
const packetCount = Number(packetOutput.streams[0]?.nb_read_packets);
if (Number.isFinite(packetCount) && packetCount > 0) {
durationSeconds = (packetCount * AAC_LC_SAMPLES_PER_PACKET) / sampleRate;
}
} catch (error) {
// An abort is the caller's intent, not a refinement failure — let it
// through. Anything else keeps the container duration we already have.
if (options?.signal?.aborted) throw error;
}
}
return {
durationSeconds,
streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
sampleRate,
channels: audioStream.channels || 2,
audioCodec,
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
};
})();
if (options?.signal) return probePromise;
audioMetadataCache.set(filePath, probePromise);
probePromise.catch(() => {
if (audioMetadataCache.get(filePath) === probePromise) {
audioMetadataCache.delete(filePath);
}
});
return probePromise;
}
export interface KeyframeAnalysis {
avgIntervalSeconds: number;
maxIntervalSeconds: number;
keyframeCount: number;
isProblematic: boolean;
}
const keyframeCache = new Map<string, Promise<KeyframeAnalysis>>();
/**
* Check keyframe intervals in a video file. Intervals > 2s cause seeking
* issues in the headless renderer and audio/video desync. Videos from
* yt-dlp --download-sections or screen recordings often have sparse keyframes.
*/
export async function analyzeKeyframeIntervals(filePath: string): Promise<KeyframeAnalysis> {
const cached = keyframeCache.get(filePath);
if (cached) return cached;
const promise = analyzeKeyframeIntervalsUncached(filePath);
keyframeCache.set(filePath, promise);
promise.catch(() => {
if (keyframeCache.get(filePath) === promise) {
keyframeCache.delete(filePath);
}
});
return promise;
}
async function analyzeKeyframeIntervalsUncached(filePath: string): Promise<KeyframeAnalysis> {
const stdout = await runFfprobe(filePath, [
"-select_streams",
"v:0",
"-skip_frame",
"nokey",
"-show_entries",
"frame=pts_time",
"-of",
"csv=p=0",
]);
const timestamps = stdout
.split("\n")
.map((line) => parseFloat(line.trim()))
.filter((t) => Number.isFinite(t));
if (timestamps.length < 2) {
return {
avgIntervalSeconds: 0,
maxIntervalSeconds: 0,
keyframeCount: timestamps.length,
isProblematic: false,
};
}
let maxInterval = 0;
let totalInterval = 0;
for (let i = 1; i < timestamps.length; i++) {
const interval = (timestamps[i] ?? 0) - (timestamps[i - 1] ?? 0);
totalInterval += interval;
if (interval > maxInterval) maxInterval = interval;
}
const avgInterval = totalInterval / (timestamps.length - 1);
return {
avgIntervalSeconds: Math.round(avgInterval * 100) / 100,
maxIntervalSeconds: Math.round(maxInterval * 100) / 100,
keyframeCount: timestamps.length,
isProblematic: maxInterval > 2,
};
}