Skip to content

Commit 4677e8c

Browse files
fix(engine): harden ffprobe parsing and command arguments (heygen-com#2740)
- parseFrameRate now rejects malformed ratios (e.g. "30/", "30/0") instead of NaN. - Add "--" before file paths so names starting with "-" are not parsed as options. - cICP PNG chunk no longer returns before IHDR supplies width and height. - Add regression tests for option injection, frame rates, and cICP ordering.
1 parent 25fb9c7 commit 4677e8c

2 files changed

Lines changed: 165 additions & 24 deletions

File tree

packages/engine/src/utils/ffprobe.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,143 @@ describe("ffprobe missing-binary fallback", () => {
502502
await expect(extractAudioMetadata("/tmp/example.mp3")).rejects.toThrow(/install FFmpeg/i);
503503
});
504504
});
505+
506+
describe("ffprobe option separator", () => {
507+
afterEach(() => {
508+
vi.resetModules();
509+
vi.doUnmock("child_process");
510+
});
511+
512+
it("places -- before the file path so paths starting with - are not parsed as options", async () => {
513+
const { spawn, calls } = createSpawnSpy([
514+
{
515+
kind: "exit",
516+
code: 0,
517+
stdout: JSON.stringify({
518+
streams: [
519+
{
520+
codec_type: "video",
521+
codec_name: "h264",
522+
width: 320,
523+
height: 180,
524+
r_frame_rate: "30/1",
525+
avg_frame_rate: "30/1",
526+
},
527+
],
528+
format: { duration: "1.5" },
529+
}),
530+
},
531+
]);
532+
vi.resetModules();
533+
vi.doMock("child_process", () => ({ spawn }));
534+
535+
const { extractMediaMetadata } = await import("./ffprobe.js");
536+
const filePath = "/tmp/-dangerous-name.mp4";
537+
await extractMediaMetadata(filePath);
538+
539+
const args = calls[0]?.args ?? [];
540+
const filePathIndex = args.indexOf(filePath);
541+
expect(filePathIndex).toBeGreaterThan(0);
542+
expect(args[filePathIndex - 1]).toBe("--");
543+
});
544+
545+
it("uses -- for audio and keyframe probes too", async () => {
546+
const { spawn, calls } = createSpawnSpy([
547+
{
548+
kind: "exit",
549+
code: 0,
550+
stdout: JSON.stringify({
551+
streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }],
552+
format: { duration: "1.25" },
553+
}),
554+
},
555+
{
556+
kind: "exit",
557+
code: 0,
558+
stdout: JSON.stringify({
559+
streams: [{ nb_read_packets: "783" }],
560+
format: {},
561+
}),
562+
},
563+
{ kind: "exit", code: 0, stdout: "0.000\n1.000\n" },
564+
]);
565+
vi.resetModules();
566+
vi.doMock("child_process", () => ({ spawn }));
567+
568+
const { extractAudioMetadata, analyzeKeyframeIntervals } = await import("./ffprobe.js");
569+
await extractAudioMetadata("/tmp/-audio.wav");
570+
await analyzeKeyframeIntervals("/tmp/-video.mp4");
571+
572+
const args = calls.flatMap((call) => [...(call.args ?? [])]);
573+
expect(args.filter((arg) => arg === "--")).toHaveLength(3);
574+
});
575+
});
576+
577+
describe("ffprobe frame rate parsing", () => {
578+
afterEach(() => {
579+
vi.resetModules();
580+
vi.doUnmock("child_process");
581+
});
582+
583+
it.each([
584+
{ r: "30/1", avg: "30/1", expected: 30 },
585+
{ r: "30000/1001", avg: "30000/1001", expected: 29.97 },
586+
{ r: "30/", avg: undefined, expected: 0 },
587+
{ r: "30/0", avg: undefined, expected: 0 },
588+
{ r: "0/0", avg: undefined, expected: 0 },
589+
{ r: "abc/def", avg: undefined, expected: 0 },
590+
{ r: "60", avg: undefined, expected: 60 },
591+
])("parses r=$r avg=$avg as fps=$expected", async ({ r, avg, expected }) => {
592+
const { spawn } = createSpawnSpy([
593+
{
594+
kind: "exit",
595+
code: 0,
596+
stdout: JSON.stringify({
597+
streams: [
598+
{
599+
codec_type: "video",
600+
codec_name: "h264",
601+
width: 320,
602+
height: 180,
603+
r_frame_rate: r,
604+
avg_frame_rate: avg,
605+
},
606+
],
607+
format: { duration: "1.5" },
608+
}),
609+
},
610+
]);
611+
vi.resetModules();
612+
vi.doMock("child_process", () => ({ spawn }));
613+
614+
const { extractMediaMetadata } = await import("./ffprobe.js");
615+
const meta = await extractMediaMetadata("/tmp/frame-rate.mp4");
616+
617+
expect(meta.fps).toBe(expected);
618+
});
619+
});
620+
621+
describe("extractPngMetadataFromBuffer cICP ordering", () => {
622+
it("does not emit color space until IHDR provides width and height", () => {
623+
const ihdr = pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]);
624+
const cicp = pngChunk("cICP", [9, 16, 0, 1]);
625+
const iend = pngChunk("IEND", []);
626+
627+
// cICP before IHDR is invalid PNG ordering; make sure we don't return
628+
// zero-sized metadata in that case.
629+
const malformed = buildPngWithChunks([cicp, ihdr, iend]);
630+
expect(extractPngMetadataFromBuffer(malformed)).toEqual({
631+
width: 1,
632+
height: 1,
633+
colorSpace: {
634+
colorPrimaries: "bt2020",
635+
colorTransfer: "smpte2084",
636+
colorSpace: "gbr",
637+
},
638+
});
639+
640+
// Without any IHDR, a cICP alone should not produce a result.
641+
const onlyCicp = buildPngWithChunks([cicp, iend]);
642+
expect(extractPngMetadataFromBuffer(onlyCicp)).toBeNull();
643+
});
644+
});

packages/engine/src/utils/ffprobe.ts

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async function runFfprobe(
4848
signal?: AbortSignal,
4949
): Promise<string> {
5050
const command = getFfprobeBinary();
51-
const proc = spawn(command, ["-v", "error", ...argsWithoutInput, filePath]);
51+
const proc = spawn(command, ["-v", "error", ...argsWithoutInput, "--", filePath]);
5252
trackChildProcess(proc);
5353
let stdout = "";
5454
proc.stdout.on("data", (data) => {
@@ -197,6 +197,7 @@ export function extractPngMetadataFromBuffer(buf: Buffer): StillImageMetadata |
197197
let width = 0;
198198
let height = 0;
199199
let seenIdat = false;
200+
let colorSpaceFromCicp: VideoColorSpace | null = null;
200201
let pos = 8;
201202
while (pos + 12 <= buf.length) {
202203
const chunkLen = buf.readUInt32BE(pos);
@@ -221,35 +222,31 @@ export function extractPngMetadataFromBuffer(buf: Buffer): StillImageMetadata |
221222
const transferCode = chunkData[1] ?? 0;
222223
const matrixCode = chunkData[2] ?? 0;
223224

224-
return {
225-
width,
226-
height,
227-
colorSpace: {
228-
colorPrimaries:
229-
primariesCode === 9
230-
? "bt2020"
231-
: primariesCode === 1
225+
colorSpaceFromCicp = {
226+
colorPrimaries:
227+
primariesCode === 9
228+
? "bt2020"
229+
: primariesCode === 1
230+
? "bt709"
231+
: `unknown-${primariesCode}`,
232+
colorTransfer:
233+
transferCode === 16
234+
? "smpte2084"
235+
: transferCode === 18
236+
? "arib-std-b67"
237+
: transferCode === 1
232238
? "bt709"
233-
: `unknown-${primariesCode}`,
234-
colorTransfer:
235-
transferCode === 16
236-
? "smpte2084"
237-
: transferCode === 18
238-
? "arib-std-b67"
239-
: transferCode === 1
240-
? "bt709"
241-
: `unknown-${transferCode}`,
242-
colorSpace:
243-
matrixCode === 9 ? "bt2020nc" : matrixCode === 0 ? "gbr" : `unknown-${matrixCode}`,
244-
},
239+
: `unknown-${transferCode}`,
240+
colorSpace:
241+
matrixCode === 9 ? "bt2020nc" : matrixCode === 0 ? "gbr" : `unknown-${matrixCode}`,
245242
};
246243
}
247244

248245
if (chunkType === "IEND") break;
249246
pos += 12 + chunkLen;
250247
}
251248

252-
return width > 0 && height > 0 ? { width, height, colorSpace: null } : null;
249+
return width > 0 && height > 0 ? { width, height, colorSpace: colorSpaceFromCicp } : null;
253250
}
254251

255252
function extractStillImageMetadata(filePath: string): StillImageMetadata | null {
@@ -283,9 +280,13 @@ function parseFrameRate(frameRateStr: string | undefined): number {
283280
if (parts.length === 2) {
284281
const num = parseFloat(parts[0] ?? "");
285282
const den = parseFloat(parts[1] ?? "");
286-
if (den !== 0) return Math.round((num / den) * 100) / 100;
283+
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
284+
return Math.round((num / den) * 100) / 100;
285+
}
286+
return 0;
287287
}
288-
return parseFloat(frameRateStr) || 0;
288+
const parsed = parseFloat(frameRateStr);
289+
return Number.isFinite(parsed) ? parsed : 0;
289290
}
290291

291292
/**

0 commit comments

Comments
 (0)