Skip to content

Commit e0c6fdf

Browse files
authored
Merge pull request #2912 from heygen-com/ffprobe-1-png
fix(engine): stop the PNG walk at cICP, anchor IHDR, use native crc32
2 parents 89e970f + 96a6e8b commit e0c6fdf

2 files changed

Lines changed: 151 additions & 8 deletions

File tree

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

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,3 +642,111 @@ describe("extractPngMetadataFromBuffer cICP ordering", () => {
642642
expect(extractPngMetadataFromBuffer(onlyCicp)).toBeNull();
643643
});
644644
});
645+
646+
describe("PNG chunk walk — integrity of the fallback itself", () => {
647+
const IHDR_4K = [0, 0, 0x0f, 0, 0, 0, 0x08, 0x70, 16, 2, 0, 0, 0];
648+
const CICP_PQ = [9, 16, 0, 1];
649+
650+
// Regression: the walk used to continue past cICP to IEND, which made
651+
// whole-file integrity a precondition for returning anything. A damaged
652+
// trailing chunk in an otherwise-good HDR PNG nulled the whole result, and
653+
// extractMediaMetadata then re-throws the ffprobe error it had swallowed
654+
// rather than using the fallback it just computed.
655+
it("returns metadata even when a chunk AFTER cICP is corrupt", () => {
656+
const bad = pngChunk("tEXt", [65, 66]);
657+
bad[bad.length - 1] ^= 0xff; // break the CRC
658+
const png = buildPngWithChunks([
659+
pngChunk("IHDR", IHDR_4K),
660+
pngChunk("cICP", CICP_PQ),
661+
pngChunk("IDAT", [0x78, 0x9c, 0x63, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01]),
662+
bad,
663+
pngChunk("IEND", []),
664+
]);
665+
expect(extractPngMetadataFromBuffer(png)).toEqual({
666+
width: 3840,
667+
height: 2160,
668+
colorSpace: { colorPrimaries: "bt2020", colorTransfer: "smpte2084", colorSpace: "gbr" },
669+
});
670+
});
671+
672+
it("survives outright truncation after cICP", () => {
673+
const png = buildPngWithChunks([pngChunk("IHDR", IHDR_4K), pngChunk("cICP", CICP_PQ)]);
674+
const truncated = Buffer.concat([png, Buffer.from([0, 0, 0x7f, 0xff, 73, 68, 65, 84])]);
675+
expect(extractPngMetadataFromBuffer(truncated)?.width).toBe(3840);
676+
});
677+
678+
// Regression: IHDR had no first-chunk anchor, so a later one overwrote the
679+
// real dimensions and the producer laid out a 1-pixel image.
680+
it("ignores a second IHDR", () => {
681+
const png = buildPngWithChunks([
682+
pngChunk("IHDR", IHDR_4K),
683+
pngChunk("cICP", CICP_PQ),
684+
pngChunk("IDAT", [0x78, 0x9c, 0x63, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01]),
685+
pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]),
686+
pngChunk("IEND", []),
687+
]);
688+
const meta = extractPngMetadataFromBuffer(png);
689+
expect(meta?.width).toBe(3840);
690+
expect(meta?.height).toBe(2160);
691+
});
692+
693+
// A truncated 8-byte IHDR used to be accepted, reading height out of the
694+
// CRC bytes; the spec length is 13.
695+
it("rejects a short IHDR rather than reading garbage dimensions", () => {
696+
const png = buildPngWithChunks([
697+
pngChunk("IHDR", [0, 0, 0, 7, 0, 0, 0, 9]),
698+
pngChunk("cICP", CICP_PQ),
699+
pngChunk("IEND", []),
700+
]);
701+
expect(extractPngMetadataFromBuffer(png)).toBeNull();
702+
});
703+
704+
it("still rejects a PNG whose IHDR or cICP itself is corrupt", () => {
705+
const badIhdr = pngChunk("IHDR", IHDR_4K);
706+
badIhdr[badIhdr.length - 1] ^= 0xff;
707+
expect(
708+
extractPngMetadataFromBuffer(buildPngWithChunks([badIhdr, pngChunk("IEND", [])])),
709+
).toBeNull();
710+
});
711+
});
712+
713+
describe("crc32 works on every runtime the package declares", () => {
714+
afterEach(() => {
715+
vi.resetModules();
716+
vi.doUnmock("node:zlib");
717+
});
718+
719+
/** Load ffprobe.ts as it would evaluate on Node 22.0/22.1. */
720+
async function loadWithoutNativeCrc32() {
721+
const actual = await vi.importActual<typeof import("node:zlib")>("node:zlib");
722+
vi.resetModules();
723+
// zlib.crc32 landed in 22.2.0, but engine and cli both declare
724+
// `"node": ">=22"` behind a major-only gate. A NAMED import of a missing
725+
// export throws at module evaluation, so ffprobe.ts would fail to load
726+
// entirely on those runtimes — before any PNG is touched.
727+
vi.doMock("node:zlib", () => ({ ...actual, crc32: undefined }));
728+
return import("./ffprobe.js");
729+
}
730+
731+
it("parses an HDR PNG identically with the native crc32 unavailable", async () => {
732+
const png = buildPngWithChunks([
733+
pngChunk("IHDR", [0, 0, 0x0f, 0, 0, 0, 0x08, 0x70, 16, 2, 0, 0, 0]),
734+
pngChunk("cICP", [9, 16, 0, 1]),
735+
pngChunk("IEND", []),
736+
]);
737+
const withNative = extractPngMetadataFromBuffer(png);
738+
expect(withNative?.colorSpace?.colorTransfer).toBe("smpte2084");
739+
740+
const fresh = await loadWithoutNativeCrc32();
741+
expect(fresh.extractPngMetadataFromBuffer(png)).toEqual(withNative);
742+
});
743+
744+
it("rejects a corrupt chunk on the fallback path too", async () => {
745+
const bad = pngChunk("IHDR", [0, 0, 0x0f, 0, 0, 0, 0x08, 0x70, 16, 2, 0, 0, 0]);
746+
bad[bad.length - 1] ^= 0xff;
747+
const png = buildPngWithChunks([bad, pngChunk("IEND", [])]);
748+
749+
const fresh = await loadWithoutNativeCrc32();
750+
expect(fresh.extractPngMetadataFromBuffer(png)).toBeNull();
751+
});
752+
});

packages/engine/src/utils/ffprobe.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// fallow-ignore-file code-duplication complexity
22
import { spawn } from "child_process";
33
import { readFileSync } from "fs";
4+
import * as zlib from "node:zlib";
45
import { basename, extname } from "path";
56
import { redactTelemetryString } from "@hyperframes/core";
67
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
@@ -167,10 +168,24 @@ interface StillImageMetadata {
167168
colorSpace: VideoColorSpace | null;
168169
}
169170

170-
function crc32(buf: Buffer): number {
171-
let crc = 0xffffffff;
172-
for (let i = 0; i < buf.length; i++) {
173-
crc ^= buf[i] ?? 0;
171+
// node:zlib's crc32 is native and takes a running seed, so the chunk type and
172+
// the chunk data can be CRC'd in sequence without concatenating them into a
173+
// throwaway buffer: ~210 ms -> ~1.3 ms on a 12 MiB PNG.
174+
//
175+
// It landed in Node 22.2.0, and this repo declares `"node": ">=22"` with a
176+
// major-only runtime gate, so 22.0 and 22.1 are still supported. A NAMED
177+
// import of a missing export throws at module evaluation — i.e. `ffprobe.ts`
178+
// would fail to load at all on those, long before any PNG is parsed — so the
179+
// namespace import plus this capability check is deliberate. Raising the
180+
// floor to 22.2.0 instead would be a user-facing support change, which does
181+
// not belong in a PNG bug fix.
182+
const nativeCrc32 = typeof zlib.crc32 === "function" ? zlib.crc32 : undefined;
183+
184+
/** Bit-at-a-time fallback for Node 22.0/22.1. Correct, just slower. */
185+
function crc32Fallback(data: Buffer, seed: number): number {
186+
let crc = seed ^ 0xffffffff;
187+
for (let i = 0; i < data.length; i++) {
188+
crc ^= data[i] ?? 0;
174189
for (let bit = 0; bit < 8; bit++) {
175190
const mask = -(crc & 1);
176191
crc = (crc >>> 1) ^ (0xedb88320 & mask);
@@ -179,6 +194,12 @@ function crc32(buf: Buffer): number {
179194
return (crc ^ 0xffffffff) >>> 0;
180195
}
181196

197+
function chunkCrc32(chunkType: string, chunkData: Buffer): number {
198+
const typeBytes = Buffer.from(chunkType, "ascii");
199+
if (nativeCrc32) return nativeCrc32(chunkData, nativeCrc32(typeBytes));
200+
return crc32Fallback(chunkData, crc32Fallback(typeBytes, 0));
201+
}
202+
182203
export function extractPngMetadataFromBuffer(buf: Buffer): StillImageMetadata | null {
183204
if (
184205
buf.length < 8 ||
@@ -205,10 +226,14 @@ export function extractPngMetadataFromBuffer(buf: Buffer): StillImageMetadata |
205226
if (pos + 12 + chunkLen > buf.length) return null;
206227
const chunkData = buf.subarray(pos + 8, pos + 8 + chunkLen);
207228
const chunkCrc = buf.readUInt32BE(pos + 8 + chunkLen);
208-
const chunkBytes = Buffer.concat([Buffer.from(chunkType, "ascii"), chunkData]);
209-
if (crc32(chunkBytes) !== chunkCrc) return null;
210-
211-
if (chunkType === "IHDR" && chunkLen >= 8) {
229+
if (chunkCrc32(chunkType, chunkData) !== chunkCrc) return null;
230+
231+
// First IHDR only. PNG permits exactly one and it must come first, but a
232+
// malformed file can carry more — without this anchor a trailing
233+
// [IHDR 1x1] silently replaced the real 4K dimensions, and the producer
234+
// laid out a one-pixel image. `>= 13` is the spec length; the old `>= 8`
235+
// accepted a truncated header and read height out of the CRC bytes.
236+
if (chunkType === "IHDR" && chunkLen >= 13 && width === 0 && height === 0) {
212237
width = buf.readUInt32BE(pos + 8);
213238
height = buf.readUInt32BE(pos + 12);
214239
}
@@ -242,6 +267,16 @@ export function extractPngMetadataFromBuffer(buf: Buffer): StillImageMetadata |
242267
};
243268
}
244269

270+
// Everything this parser extracts has been found, so stop walking.
271+
//
272+
// Not just an optimisation: cICP must precede IDAT (enforced above), so
273+
// continuing only ever visits chunks we ignore — while making whole-file
274+
// integrity a precondition for returning anything. A truncated or
275+
// bad-CRC trailing chunk in an otherwise-good HDR PNG used to null the
276+
// entire result, and the caller then re-throws the swallowed ffprobe
277+
// error instead of using the fallback it just computed.
278+
if (width > 0 && height > 0 && colorSpaceFromCicp !== null) break;
279+
245280
if (chunkType === "IEND") break;
246281
pos += 12 + chunkLen;
247282
}

0 commit comments

Comments
 (0)