Skip to content

fix(engine): stop the PNG walk at cICP, anchor IHDR, use native crc32 - #2912

Open
vanceingalls wants to merge 1 commit into
mainfrom
ffprobe-1-png
Open

fix(engine): stop the PNG walk at cICP, anchor IHDR, use native crc32#2912
vanceingalls wants to merge 1 commit into
mainfrom
ffprobe-1-png

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

Stack 1/6. Follow-up to #2740, which is already merged.

What

Three defects in the PNG metadata fallback, all consequences of #2740 turning the cICP early return into an accumulator.

A corrupt trailing chunk nulls a good result. cICP must precede IDAT (the parser enforces !seenIdat), so continuing past it only visits chunks this parser ignores — while making whole-file integrity a precondition for returning anything. A truncated or bad-CRC chunk after cICP in an otherwise-good HDR PNG now returns null.

That is not a quiet degradation. extractMediaMetadata re-throws the ffprobe error it had swallowed:

} catch (error) {
  if (!stillImageMeta) throw error;
}

So on a host without FFmpeg the render dies on a file the pure-JS fallback used to handle; on a build that reports colour but does not decode cICP, the still grades SDR. A/B: OLD returned {3840x2160, bt2020/smpte2084}, NEW returns null.

A second IHDR overwrote the dimensions. PNG permits exactly one, first, but nothing enforced that — cICP has a !seenIdat guard, IHDR had none. [IHDR 3840x2160][cICP][IDAT][IHDR 1x1][IEND] gave OLD 3840x2160, NEW 1x1, and the producer laid out a one-pixel image. The length guard was also >= 8 against a spec length of 13, so a truncated header was accepted and height read out of the CRC bytes.

crc32 became a whole-file cost. Hand-rolled bit-at-a-time, fed a Buffer.concat per chunk purely to prepend a 4-byte type tag. Since the walk no longer stopped early it hashed entire files, synchronously on the event loop:

file before
12 MiB PNG 219.9 ms
24.9 MiB 434 ms
35.5 MiB 4K rgb48be (9071 chunks) 647 ms

Plus ~11.5 MB of garbage per parse from the concat. node:zlib's crc32 is native and takes a running seed, so type and data hash in sequence with no copy: 210.28 ms → 1.291 ms.

Verification

5 regressions — corrupt-after-cICP, truncation after cICP, second IHDR, short IHDR, and that a corrupt IHDR/cICP still rejects. Reverting the break or the anchor fails 3. Engine suite: 1280 pass.

🤖 Generated with Claude Code

Three defects in the PNG metadata fallback, all introduced when the
cICP early return became an accumulator.

Corrupt trailing chunk nulls a good result. cICP must precede IDAT, so
continuing past it only visits chunks this parser ignores — while making
whole-file integrity a precondition for returning anything. A truncated
or bad-CRC chunk after cICP in an otherwise-good HDR PNG returned null,
and extractMediaMetadata then re-throws the ffprobe error it had
swallowed instead of using the fallback it just computed: the render
dies on a host without FFmpeg, or grades SDR on a build that does not
decode cICP. Now stops once dimensions and colour are known.

A second IHDR overwrote the dimensions. PNG permits exactly one, first,
but nothing enforced that here — a trailing [IHDR 1x1] replaced a real
3840x2160 and the producer laid out a one-pixel image. Anchored to the
first. The length guard was also `>= 8` against a spec length of 13,
which accepted a truncated header and read height out of the CRC bytes.

crc32 was hand-rolled bit-at-a-time and fed a Buffer.concat per chunk.
Since the walk no longer stops early it CRC'd whole files: 210 ms on a
12 MiB PNG, 647 ms on a 35 MiB 4K one, synchronously on the event loop,
plus ~11 MB of garbage per parse from concatenating a 4-byte type tag
onto every chunk. node:zlib's crc32 is native and takes a running seed,
so type and data hash in sequence with no copy. 210.28 ms -> 1.291 ms.

Tests: 5 regressions — corrupt-after-cICP, truncation after cICP,
second IHDR, short IHDR, and that a corrupt IHDR/cICP still rejects.
Reverting the break or the anchor fails 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stack 1/6: PNG walk fixes + native CRC — Review

Clean fix, no concerns.

Verified

  • chunkCrc32(chunkType, chunkData) uses node:zlib's native crc32 with a running seed — no Buffer.concat allocation, hashes type then data in sequence. Engine's package.json requires node >= 22, which is when zlib.crc32 landed. Correct.
  • IHDR anchor: chunkLen >= 13 (spec length, was 8 — truncated IHDR used to read height out of CRC bytes), and width === 0 && height === 0 guard rejects subsequent IHDRs. Both conditions are necessary.
  • Early break: if (width > 0 && height > 0 && colorSpaceFromCicp !== null) break — stops the walk the moment everything this parser extracts is found. cICP must precede IDAT (enforced by the !seenIdat guard above), so anything after it is chunks this parser ignores. Continuing made whole-file integrity a precondition for returning anything.
  • 5 regression tests cover the exact failure modes: corrupt-after-cICP, truncation-after-cICP, second IHDR, short IHDR, corrupt IHDR/cICP itself. Each test is self-contained with explicit byte payloads.
  • The hand-rolled CRC removal leaves no dead code.

Ships clean.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 2af3f4d0ed9fac2fadc1266bcadde04e6513d800. The CRC chaining, early cICP exit, IHDR anchoring, and corruption tests are correct, and exact-head CI is terminal green. One compatibility blocker remains:

P1 — the named node:zlib crc32 import breaks runtimes the package still declares supported. zlib.crc32 was added in Node 22.2.0, but both packages/engine/package.json and packages/cli/package.json declare node >=22, and runtimeVersion.test.ts explicitly accepts 22.0.0. On Node 22.0/22.1, importing ffprobe.ts fails at module evaluation because node:zlib has no crc32 export—before any PNG path runs. Raise the enforced/package runtime floor consistently to >=22.2.0, or retain a compatible fallback, and add a minimum-supported-runtime import smoke. Official API history: https://nodejs.org/download/release/v22.17.0/docs/api/zlib.html#zlibcrc32data-value

No other P0-P2 findings. No merge performed.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent read at 2af3f4d, third perspective following @miga-heygen. Agree with their verified matrix — the IHDR >= 13 + width === 0 && height === 0 double-guard is both necessary, the early break at cICP is documented as a correctness fix (not just an optimisation, since it makes whole-file integrity a precondition for returning anything), and the CRC swap is byte-correct with crc32(chunkData, crc32(Buffer.from(chunkType, "ascii"))) using the running-seed API cleanly.

Only one angle to add:

Nits

  • node:zlib.crc32 minor-version dependency. crc32 was added to node:zlib in Node 22.2.0 (May 2024) — engine's "node": ">=22" engines constraint permits 22.0.x and 22.1.x, where this would throw at import time. CI is on actions/setup-node@v4 with node-version: 22, which resolves to latest 22.x on GitHub-hosted runners, so no CI risk. But a developer or CI runner pinned to an early 22 minor could hit it. Tightening the engines to ">=22.2" (or the current LTS 22.11+) would make it a load-time error at npm install rather than a runtime throw. Non-blocking — real-world exposure is tiny.

Otherwise the ship-list stands. LGTM from my side.

Review by Rames D Jusso

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants