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
This commit is contained in:
James Russo
2026-08-03 18:16:41 -07:00
committed by GitHub
parent 1d0d4d8939
commit 9792c32950
21 changed files with 1759 additions and 159 deletions
+288 -2
View File
@@ -1,6 +1,8 @@
// fallow-ignore-file code-duplication
import { EventEmitter } from "events";
import { readFileSync } from "fs";
import { spawnSync } from "child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { basename, resolve } from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -113,6 +115,272 @@ describe("extractPngMetadataFromBuffer", () => {
);
expect(extractPngMetadataFromBuffer(fixture)?.colorSpace?.colorTransfer).toBe("smpte2084");
});
it("keeps metadata fallback independent from full PNG integrity validation", () => {
const ihdr = pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]);
expect(extractPngMetadataFromBuffer(buildPngWithChunks([ihdr]))).toMatchObject({
width: 1,
height: 1,
});
});
});
describe("probeMediaProfile", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("child_process");
});
it("classifies still, moving, audio-only, and mixed streams from probe data", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [{ codec_type: "video", codec_name: "png" }],
format: { format_name: "png_pipe" },
}),
},
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [{ codec_type: "video", codec_name: "h264" }],
format: { format_name: "mov,mp4,m4a,3gp,3g2,mj2" },
}),
},
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [{ codec_type: "audio", codec_name: "mp3" }],
format: { format_name: "mp3" },
}),
},
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [{ codec_type: "video" }, { codec_type: "audio" }],
format: { format_name: "matroska,webm" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { probeMediaProfile } = await import("./ffprobe.js");
const validPngPath = resolve(
__dirname,
"../../../producer/tests/hdr-regression/src/hdr-photo-pq.png",
);
await expect(probeMediaProfile(validPngPath)).resolves.toEqual({
hasVideoStream: true,
hasAudioStream: false,
visualKind: "still",
});
await expect(probeMediaProfile("/tmp/extensionless-video")).resolves.toEqual({
hasVideoStream: true,
hasAudioStream: false,
visualKind: "moving",
});
await expect(probeMediaProfile("/tmp/extensionless-audio")).resolves.toEqual({
hasVideoStream: false,
hasAudioStream: true,
visualKind: "none",
});
await expect(probeMediaProfile("/tmp/mixed-av")).resolves.toEqual({
hasVideoStream: true,
hasAudioStream: true,
visualKind: "moving",
});
});
it("does not treat attached cover art in an audio container as an image asset", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{ codec_type: "video", disposition: { attached_pic: 1 } },
{ codec_type: "audio" },
],
format: { format_name: "mp3" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { probeMediaProfile } = await import("./ffprobe.js");
await expect(probeMediaProfile("/tmp/audio-with-cover")).resolves.toMatchObject({
hasAudioStream: true,
visualKind: "none",
});
});
it("deduplicates probes within one cancellation scope without sharing across scopes", async () => {
const fixtureDir = mkdtempSync(resolve(tmpdir(), "hf-media-probe-cache-"));
const fixturePath = resolve(fixtureDir, "asset");
writeFileSync(fixturePath, "cache identity only");
const outcome = {
kind: "exit" as const,
code: 0,
stdout: JSON.stringify({
streams: [{ codec_type: "video", codec_name: "h264" }],
format: { format_name: "mov,mp4,m4a,3gp,3g2,mj2" },
}),
};
const { spawn, calls } = createSpawnSpy([outcome, outcome]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { probeMediaProfile } = await import("./ffprobe.js");
const firstSignal = new AbortController().signal;
const secondSignal = new AbortController().signal;
try {
await Promise.all([
probeMediaProfile(fixturePath, { signal: firstSignal }),
probeMediaProfile(fixturePath, { signal: firstSignal }),
]);
expect(calls).toHaveLength(1);
await probeMediaProfile(fixturePath, { signal: secondSignal });
expect(calls).toHaveLength(2);
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
});
it("bounds the process-scoped probe cache", async () => {
const fixtureDir = mkdtempSync(resolve(tmpdir(), "hf-media-probe-lru-"));
const fixturePaths = Array.from({ length: 129 }, (_, index) =>
resolve(fixtureDir, `asset-${index}`),
);
for (const fixturePath of fixturePaths) writeFileSync(fixturePath, "probe identity");
const outcome = {
kind: "exit" as const,
code: 0,
stdout: JSON.stringify({
streams: [{ codec_type: "audio", codec_name: "aac" }],
format: { format_name: "aac" },
}),
};
const { spawn, calls } = createSpawnSpy([outcome]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { probeMediaProfile } = await import("./ffprobe.js");
try {
for (const fixturePath of fixturePaths) await probeMediaProfile(fixturePath);
await probeMediaProfile(fixturePaths[0]!);
expect(calls).toHaveLength(130);
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
});
it("classifies extensionless AVIF from its ISO-BMFF brand instead of the generic mov demuxer", async () => {
const fixtureDir = mkdtempSync(resolve(tmpdir(), "hf-avif-profile-"));
const fixturePath = resolve(fixtureDir, "asset");
const ftyp = Buffer.alloc(24);
ftyp.writeUInt32BE(24, 0);
ftyp.write("ftyp", 4, 4, "ascii");
ftyp.write("avif", 8, 4, "ascii");
ftyp.writeUInt32BE(0, 12);
ftyp.write("mif1", 16, 4, "ascii");
ftyp.write("avif", 20, 4, "ascii");
writeFileSync(fixturePath, ftyp);
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [{ codec_type: "video", codec_name: "av1" }],
format: { format_name: "mov,mp4,m4a,3gp,3g2,mj2" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { probeMediaProfile } = await import("./ffprobe.js");
try {
await expect(probeMediaProfile(fixturePath)).resolves.toMatchObject({
hasVideoStream: true,
hasAudioStream: false,
visualKind: "still",
});
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
});
it("uses any non-attached video stream when cover art precedes moving video", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{ codec_type: "video", disposition: { attached_pic: 1 } },
{ codec_type: "video", codec_name: "h264" },
{ codec_type: "audio" },
],
format: { format_name: "mov,mp4,m4a,3gp,3g2,mj2" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { probeMediaProfile } = await import("./ffprobe.js");
await expect(probeMediaProfile("/tmp/video-with-cover")).resolves.toMatchObject({
visualKind: "moving",
});
});
it.skipIf(spawnSync("ffprobe", ["-version"]).status !== 0)(
"rejects an IHDR-only truncated PNG even when ffprobe accepts png_pipe",
async () => {
vi.resetModules();
vi.doUnmock("child_process");
const fixtureDir = mkdtempSync(resolve(tmpdir(), "hf-truncated-png-profile-"));
const fixturePath = resolve(fixtureDir, "asset");
const ihdr = pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]);
writeFileSync(fixturePath, buildPngWithChunks([ihdr]));
const { probeMediaProfile } = await import("./ffprobe.js");
try {
await expect(probeMediaProfile(fixturePath)).rejects.toThrow();
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
},
);
it("does not turn an aborted PNG probe into a successful metadata fallback", async () => {
type KillableFakeProc = FakeProc & { kill: (signal?: NodeJS.Signals) => boolean };
const spawn = () => {
const proc = new EventEmitter() as KillableFakeProc;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn(() => {
process.nextTick(() => proc.emit("close", null, "SIGTERM"));
return true;
});
process.nextTick(() => proc.emit("spawn"));
return proc;
};
const fixtureDir = mkdtempSync(resolve(tmpdir(), "hf-aborted-png-profile-"));
const fixturePath = resolve(fixtureDir, "asset");
writeFileSync(fixturePath, buildMinimalPng());
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { probeMediaProfile } = await import("./ffprobe.js");
const controller = new AbortController();
try {
const pending = probeMediaProfile(fixturePath, { signal: controller.signal });
controller.abort(new Error("render cancelled"));
await expect(pending).rejects.toThrow("render cancelled");
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
});
});
interface SpawnCall {
@@ -222,6 +490,23 @@ describe("ffprobe missing-binary fallback", () => {
expect(calls[0]?.args.slice(0, 2)).toEqual(["-v", "error"]);
});
it("does not accept an incomplete PNG through the missing-binary fallback", async () => {
const fixtureDir = mkdtempSync(resolve(tmpdir(), "hf-truncated-png-fallback-"));
const fixturePath = resolve(fixtureDir, "asset");
const ihdr = pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]);
writeFileSync(fixturePath, buildPngWithChunks([ihdr]));
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { probeMediaProfile } = await import("./ffprobe.js");
try {
await expect(probeMediaProfile(fixturePath)).rejects.toThrow(/ffprobe/i);
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
});
// `profile` matters now: the packet refinement is an allowlist on AAC-LC,
// because the 1024-sample formula is wrong for LD/ELD/HE and unverified for
// the rest. An unprofiled "aac" stream deliberately keeps its container
@@ -715,11 +1000,12 @@ describe("extractPngMetadataFromBuffer cICP ordering", () => {
it("does not emit color space until IHDR provides width and height", () => {
const ihdr = pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]);
const cicp = pngChunk("cICP", [9, 16, 0, 1]);
const idat = pngChunk("IDAT", [0x78, 0x9c, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]);
const iend = pngChunk("IEND", []);
// cICP before IHDR is invalid PNG ordering; make sure we don't return
// zero-sized metadata in that case.
const malformed = buildPngWithChunks([cicp, ihdr, iend]);
const malformed = buildPngWithChunks([cicp, ihdr, idat, iend]);
expect(extractPngMetadataFromBuffer(malformed)).toEqual({
width: 1,
height: 1,
+234 -17
View File
@@ -1,9 +1,9 @@
// fallow-ignore-file code-duplication complexity
import { spawn } from "child_process";
import { readFileSync } from "fs";
import { createReadStream, readFileSync, statSync } from "fs";
import * as zlib from "node:zlib";
import { StringDecoder } from "node:string_decoder";
import { basename, extname } from "path";
import { basename } from "path";
import { redactTelemetryString } from "@hyperframes/core";
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
import { ManagedChildProcess } from "./managedChildProcess.js";
@@ -147,6 +147,14 @@ const finalVideoFrameTimestampSignalCaches = new WeakMap<
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;
@@ -213,11 +221,13 @@ interface FFProbeStream {
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 {
@@ -231,6 +241,226 @@ interface StillImageMetadata {
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.
@@ -365,8 +595,6 @@ export function pixelFormatHasAlpha(pixelFormat: string): boolean {
}
function extractStillImageMetadata(filePath: string): StillImageMetadata | null {
if (extname(filePath).toLowerCase() !== ".png") return null;
try {
return extractPngMetadataFromBuffer(readFileSync(filePath));
} catch {
@@ -477,13 +705,7 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
let output: FFProbeOutput | null = null;
try {
const stdout = await runFfprobe(filePath, [
"-print_format",
"json",
"-show_format",
"-show_streams",
]);
output = parseProbeJson(stdout);
output = await probeMediaOutput(filePath);
} catch (error) {
if (!stillImage()) throw error;
}
@@ -679,12 +901,7 @@ export async function extractAudioMetadata(
if (cached) return cached;
const probePromise = (async (): Promise<AudioMetadata> => {
const stdout = await runFfprobe(
filePath,
["-print_format", "json", "-show_format", "-show_streams"],
options?.signal,
);
const output = parseProbeJson(stdout);
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");