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