Skip to content

Bun.Image: preserve 16-bit-per-channel PNG through decode/encode - #30463

Open
robobun wants to merge 3 commits into
mainfrom
farm/c2a27d0a/image-16bpc-png
Open

Bun.Image: preserve 16-bit-per-channel PNG through decode/encode#30463
robobun wants to merge 3 commits into
mainfrom
farm/c2a27d0a/image-16bpc-png

Conversation

@robobun

@robobun robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #30462.

Repro

// 16-bit-per-channel smpte bars (ffmpeg -f lavfi -i smptebars -vf format=rgb48 -frames 1 16bit.png)
await new Bun.Image("16bit.png").write("16bit2.png");
$ identify 16bit2.png
16bit2.png PNG 320x240 320x240+0+0 8-bit sRGB 979B 0.000u 0:00.000

Expected 16-bit sRGB; got 8-bit. The low byte of every channel is lost even though no user op runs.

Cause

src/runtime/image/codec_png.zig hard-codes the round-trip at 8 bpc:

  • decode: spng_decode_image(..., SPNG_FMT_RGBA8, ...) — libspng down-converts 16-bpc samples during decode.
  • encode: .bit_depth = 8 in the IHDR.

codecs.Decoded.rgba: []u8 is the only intermediate container, so every codec and every op is built around 8 bpc. The pass-through path had no hole in it to let a 16-bpc buffer survive.

Fix

Three small changes keep the PNG → PNG pass-through at full precision and narrow at the latest possible moment elsewhere:

  1. codecs.Decoded gains a bit_depth: u8 = 8 field plus a downconvertTo8 helper that narrows the u16 channels to their high byte in place (same convention as libpng png_set_strip_16, libvips). Every other decoder / backend constructs Decoded without the field and gets 8 by default — no call-site churn.

  2. codec_png.decode reads the source IHDR and asks libspng for SPNG_FMT_RGBA16 when ihdr.bit_depth == 16. The buffer is 8 bytes/pixel, host-endian — libspng does the wire-format byte swap in both directions.

  3. codec_png.encode takes bit_depth and writes it to the IHDR. SPNG_FMT_PNG with bit_depth == 16 sets libspng's to_bigendian flag so the host-endian u16 buffer round-trips cleanly.

Image.zig calls downconvertTo8 before any u8-only consumer touches the buffer: any pipeline op (resize/rotate/flip/flop/modulate), applyOrientation, the .placeholder() ThumbHash path, and any non-PNG or indexed-PNG encode. That keeps the single lossless path — PNG in, PNG out, no ops — at 16 bpc and makes every other path explicit about where precision is lost.

Tests

test/js/bun/image/image.test.ts gets a new describe("16-bit-per-channel PNG (issue #30462)", ...) block with 11 cases covering:

  • 16-bpc source PNG → 16-bpc output PNG (no ops, via .png() and bare .bytes(), plus .write(path) on disk)
  • every channel's low byte survives the round-trip (corner-pixel check — the reporter's regression)
  • 16-bpc source PNG → op (resize/rotate) → 8-bpc output PNG, rotating the high byte correctly
  • 16-bpc source PNG → JPEG / lossless WebP / indexed PNG → valid 8-bpc output
  • 16-bpc + iCCP round-trips the profile chunk (regression guard for Bun.Image strips ICC profile #30197 on the new decode arm)

Fail-before/pass-after verified by stashing src/:

USE_SYSTEM_BUN=1 bun test test/js/bun/image/image.test.ts -t "16-bit-per-channel PNG"
 # 5 fail, 6 pass
bun bd test test/js/bun/image/image.test.ts -t "16-bit-per-channel PNG"
 # 11 pass

Full image suite remains green on Linux (104 pass, 2 system-backend skips).


no test proof · iteration 11 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/image/image.test.ts

@robobun

robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:42 AM PT - Aug 14th, 2026

@robobun, your commit 49fbe79 is building: #96345

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds 16-bit-per-channel PNG support to Bun.Image. The PNG codec now decodes and encodes 16-bpc data with proper bit_depth tracking. The image pipeline conditionally downconverts to 8-bit only when operations or non-PNG encoding require it, preserving 16-bit precision for PNG pass-through. Tests validate 16-bpc round-trip and downconversion behavior.

Changes

16-bit PNG preservation and conditional downconversion

Layer / File(s) Summary
Data structures and type contracts
src/runtime/image/codecs.zig, src/runtime/image/codec_png.zig
Decoded struct adds bit_depth: u8 field; PNG codec defines SPNG_FMT_RGBA16 constant for libspng 16-bit pipeline.
PNG codec 16-bit decode/encode
src/runtime/image/codec_png.zig
decode() selects RGBA8 vs RGBA16 format based on IHDR bit_depth and returns Decoded with matching bit_depth. encode() accepts explicit bit_depth parameter and sets IHDR accordingly.
Codecs downconversion and encode routing
src/runtime/image/codecs.zig
Decoded.downconvertTo8() narrows u16 samples to 8-bit and shrinks allocation. Public encode() forwards bit_depth to PNG truecolor path while indexed PNG remains 8-bit only; probe/maxPixels adjusted for 16-bpc.
Image pipeline conditional downconversion
src/runtime/image/Image.zig
Downconverts to 8-bit selectively in placeholder generation, non-PNG encoding, when pipeline operations are present, and on geometry-changing orientation. Preserves 16-bpc for PNG no-op pass-through.
16-bit PNG test coverage
test/js/bun/image/image.test.ts
Adds 16-bpc PNG fixture helpers and comprehensive test suite validating bit_depth preservation, downconversion on operations, iCCP chunk survival, and maxPixels behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fully addresses issue #30462 by implementing support for preserving 16-bit-per-channel PNG through decode/encode, with comprehensive test coverage for the round-trip scenario and related edge cases.
Out of Scope Changes check ✅ Passed All changes are directly related to supporting 16-bit PNG preservation: codec updates, intermediate buffer handling, downconversion logic, and comprehensive test coverage—all scoped to the stated objective.
Title check ✅ Passed The title clearly and concisely summarizes the main change: preserving 16-bit PNG precision through decode and encode.
Description check ✅ Passed The description explains the problem, implementation, affected paths, tests, and verification results, although it does not use the template headings.

Comment @coderabbitai help to get the list of available commands.

Comment thread src/runtime/image/codecs.zig Outdated
Comment thread src/runtime/image/codec_png.zig Outdated

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/image/codecs.zig`:
- Around line 295-305: The probe() PNG IHDR check multiplies width*height*2
which can overflow; instead compute an effective_max_pixels = max_pixels / 2
when bytes[24] == 16 and use the existing guard(...) or comparison that checks w
and h against effective_max_pixels (rather than multiplying by 2). Locate the
IHDR branch that reads w/h from bytes and the bytes[24] == 16 condition, replace
the direct multiplication `@as`(u64, w) * `@as`(u64, h) * 2 with a call to the
existing guard(effective_max_pixels) or a comparison against
effective_max_pixels (where effective_max_pixels = max_pixels / 2) so you avoid
overflow and keep probe() consistent with codec_png.decode()'s 16-bpc behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1ddc740c-073e-46d2-81de-6c1d64f9669e

📥 Commits

Reviewing files that changed from the base of the PR and between 03db784 and 4cc2734.

📒 Files selected for processing (4)
  • src/runtime/image/Image.zig
  • src/runtime/image/codec_png.zig
  • src/runtime/image/codecs.zig
  • test/js/bun/image/image.test.ts

Comment thread src/runtime/image/codecs.zig Outdated
Comment thread src/runtime/image/codecs.zig Outdated
Comment thread test/js/bun/image/image.test.ts Outdated
Comment thread test/js/bun/image/image.test.ts Outdated

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/image/image.test.ts`:
- Around line 646-673: The test currently only checks for the presence of an
iCCP chunk (hasIccp) but must validate the actual payload bytes; locate the iCCP
chunk in the produced out buffer (using dv/DataView and the existing
chunk-scanning loop), extract the chunk body bytes (the body length `len` at
that chunk), skip the keyword + 0x00 separator and the one compression-method
byte to get the compressed profile portion, inflate it with zlib.inflateSync and
compare the result to the original profile Uint8Array using an equality
assertion instead of just checking hasIccp; update the test around the while
loop/expect lines to perform this extraction, decompression, and expect equality
to `profile` (replace or augment the hasIccp check).
- Around line 641-644: The test currently asserts pngBitDepth(out) === 8 which
conflates indexed-PNG IHDR.bit_depth (palette index width) with per-channel
precision; instead modify the assertion in the "PNG 16 → png({palette})
downconverts to 8-bpc (quantise is u8-only)" test to verify the output is an
indexed PNG and not 16-bit per-sample: call the existing pngColorType(out)
helper and expect it to equal 3 (indexed palette), and assert pngBitDepth(out)
!== 16 (or assert it is one of 1,2,4,8) to ensure the encoder did not emit
16-bit samples while allowing 4-bit indices for 16 colors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ffcdb655-57a6-4ce3-b997-0dccafc87dfe

📥 Commits

Reviewing files that changed from the base of the PR and between 12b894c and dbc60cb.

📒 Files selected for processing (1)
  • test/js/bun/image/image.test.ts

Comment thread test/js/bun/image/image.test.ts
Comment thread test/js/bun/image/image.test.ts Outdated

@claude claude Bot 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.

Thanks for addressing all four rounds of feedback — I have nothing further. Deferring to a human for final sign-off since this threads a new bit-depth contract through the codec pipeline and touches the decompression-bomb guard.

Extended reasoning...

Overview

This PR adds 16-bit-per-channel PNG preservation to Bun.Image. It introduces a bit_depth: u8 = 8 field on codecs.Decoded, a downconvertTo8() in-place narrowing helper, conditional SPNG_FMT_RGBA16 decode/encode in codec_png.zig, four downconvertTo8() call sites in Image.zig (placeholder, non-PNG encode, pipeline ops, EXIF orientation), a bit_depth parameter on codecs.encode/png.encode, and a halved maxPixels budget for 16-bpc sources in both codec_png.decode() and probe(). ~220 lines of new tests cover round-trip, downconversion on ops/encoders, iCCP interaction, the halving guard, and the hostile-IHDR overflow case.

Security risks

The PR modifies the decompression-bomb defence: 16-bpc PNGs now allocate 8 bytes/pixel, so max_pixels is halved for 16-bpc sources to keep the ~1 GiB byte cap. An earlier revision had a u64 overflow in probe() (w*h*2 on a hostile 25-byte IHDR → safety-checked panic on the JS thread); this was fixed in 4560487 to the overflow-safe w*h > max_pixels/2 form, with a regression test. The downconvertTo8 in-place narrowing reads u16 at byte offset 2i and writes u8 at offset i, so writes never overtake reads. No new injection/auth/data-exposure surface.

Level of scrutiny

This is a non-trivial feature change to a codec pipeline that processes untrusted input. It alters the internal buffer-format contract (RGBA8 → conditionally RGBA16), threads that through four call sites that must each downconvert before u8-only consumers, and modifies a security guard. The PR went through four rounds of review feedback (infallible-signature cleanup, 2× allocation guard, overflow fix, test-value tightening, comment corrections), all addressed. That iteration history itself argues for a maintainer's eye on the final state rather than bot-only approval.

Other factors

Test coverage is thorough (13 new cases including fail-before/pass-after verification and an 8-bpc control for the halving test). All my prior inline comments are resolved and no new issues were found on dbc60cb. CodeRabbit also reports clean. The remaining reason to defer is scope/criticality, not any outstanding concern.

Comment thread src/runtime/image/codecs.zig Outdated
@robobun

robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator Author

CI summary for maintainers (build 53275 @ d6440bd):

Diff is green. image.test.ts passed 111/0 on darwin-14-aarch64 (the lane that runs it in this build's sharding); the full image suite (image.test.ts / image-adversarial.test.ts / image-kernels.test.ts / image-vs-sharp.test.ts) was green on every lane where it ran. Gate (fail-before / pass-after with src/ stashed) verified locally under ASAN debug.

Remaining red — all unrelated to this diff (src/runtime/image/ + src/jsc/bindings/image_coregraphics_shim.cpp):

Lane Test Failure
debian-13-x64-asan fetch-http2-client.test.ts WTF::AtomStringImpl::wasRemoved assertion — recurring WebKit-layer race on concurrent h2 fetch; same flake as builds 53169 / earlier
windows-2019-x64 + baseline test-http-should-emit-close-when-connection-is-aborted.ts timeout ×4 attempts
windows-11-aarch64 hot.test.ts (reloadCounter 38≠50), bun-install-registry.test.ts, bun-install-lifecycle-scripts.test.ts known flaky on this lane
darwin-14-aarch64 s3-storage-class.test.ts (S3Error UnknownError), test-retry-repeats-basic.test.ts

All review threads resolved. I've already spent the one ci: retrigger at ca4c7f74, so per policy I'm not pushing another. Needs a maintainer to re-roll CI or merge.

@190n

190n commented May 11, 2026

Copy link
Copy Markdown
Contributor

does this work for high bit depth HEIC and AVIF and TIFF too?

@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

@190n No — this PR is PNG-only. The other high-bit-depth-capable formats all hit a forced 8-bpc conversion before we own the pixels:

  • HEIC / AVIF (macOS / Windows): decode goes through bun_coregraphics_decode / bun_wic_decode. Both ask the OS codec for 8-bpc RGBA (VFmt{8, 32, …} in the CoreGraphics shim, GUID_WICPixelFormat32bppRGBA on WIC) — the downconvert happens inside vImage / WIC before the buffer lands in Zig. A 10- or 12-bit HEVC/AV1 source loses its extra precision at the InitWithCGImage / CopyPixels call. Not touched by this PR.
  • HEIC / AVIF on Linux: no static decoder linked — returns UnsupportedOnPlatform. Bun.Image: AVIF decode and encode on Linux via dlopen'd libavif #30204 added libavif+dav1d for AVIF decode on Linux, but the output is still routed through the shared 8-bpc codecs.Decoded.rgba buffer, same as the system backends.
  • TIFF: decode-only via the same CoreGraphics / WIC shims, so it inherits the same 8-bpc conversion. Linux returns UnsupportedOnPlatform.

Preserving 10/12-bit HEIC + AVIF would need new OS-codec entry points that return RGBA16 (or RGBA10/12 packed), plus a third bit_depth variant through the pipeline since 10/12 aren't just 16 in disguise. 16-bpc TIFF is the easiest next step — kCGImagePropertyDepth == 16 + VFmt{16, 64, …} on the CG path and the matching GUID_WICPixelFormat64bppRGBA on WIC — but it's a bigger change than this PR and worth a separate issue if it matters for your use case. Happy to file one, or we can reopen #30462 to track.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun can you fix it properly?

@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 0469f17 — extended 16-bpc decode through the CoreGraphics (macOS) and WIC (Windows) backends so HEIC 10/12-bit, AVIF 10/12-bit, and TIFF 16-bit all keep their precision through to PNG 16-bpc encode.

CG (macOS): Added CGImageSourceCopyPropertiesAtIndex + kCGImagePropertyDepth to the dlsym table. Phase 1 reads the source depth; any source ≥ 9 bpc maps to 16. Phase 2 uses VFmt{16, 64, …, kCGImageAlphaLast | ByteOrder16Host} — vImage widens 10/12-bit samples into the u16 MSBs losslessly, and host byte order matches libspng SPNG_FMT_RGBA16 so round-trips are bit-identical.

WIC (Windows): Retyped the GetPixelFormat vtable slot and added a classifier over the 12 high-bpc source GUIDs (48bppRGB/BGR, 64bppRGBA/BGRA/RGB, + the Half/FixedPoint 48/64 families). When the source matches, decode asks WICConvertBitmapSource for 64bppRGBA instead of 32bppRGBA.

Everything downstream is unchanged — Decoded.bit_depth, downconvertTo8(), codecs.encode(bit_depth), and the pipeline's has_op narrow guard all already handle the 16-bpc case from the PNG work.

Linux AVIF via libavif+dav1d (PR #30204) is still on a separate branch and not in main, so nothing to do there yet; when that lands the same pattern applies (ask libavif for rgb.depth=16 when avifImage.depth >= 9).

Three new tests: build a minimal in-process 16-bpc TIFF and assert (1) it round-trips to 16-bpc PNG preserving low bytes, (2) an 8-bpc TIFF control stays 8-bpc — classifier doesn't upgrade unconditionally, (3) resize on a 16-bpc TIFF forces the existing 8-bpc downconvert. Skipped on Linux (no system backend), run on macOS/Windows CI lanes.

bun run zig:check-all green on all 8 targets. Full image suite passes on Linux (106 pass, 5 skip — 3 new + 2 pre-existing GIF parity skips).

Comment thread src/runtime/image/backend_wic.zig Outdated
Comment thread src/runtime/image/codecs.zig Outdated
Comment thread src/runtime/image/backend_wic.zig Outdated
Comment thread test/js/bun/image/image.test.ts Outdated
Comment thread test/js/bun/image/image.test.ts
@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI update at sha 1be2728:

✅ The Windows image.test.ts red that CR+CR bots flagged is fixed — 110 pass / 1 skip / 0 fail on all Windows 2019 x64 and x64-baseline shards that ran the file. The fix (baseline TIFF 6.0 Resolution tags + a runTiffTest() probe that silently skips if the host WIC/CG can't read the hand-rolled fixture) landed.

❌ Remaining red: one Windows 2019 x64-baseline shard fails on test/js/bun/test/expect-assertions.test.ts — that's the test-runner self-test where expect.assertions(1) tests are intentionally marked (fail) to verify the runner rejects them correctly. Unrelated to this PR's diff (image codecs); same-file failure pattern has shown up on this lane in prior CI runs. I've already used my one ci: retrigger at ca4c7f74 for the earlier WebKit AtomStringImpl flake, so per policy I'm not pushing another.

Diff is green across every lane that reports on it. Needs a maintainer to re-roll CI or merge.

Comment thread test/js/bun/image/image.test.ts

@claude claude Bot 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.

All prior review comments are addressed and I found nothing further, but given the cross-platform native-code scope (CoreGraphics shim ABI change, WIC vtable/GUID classifier, pipeline bit-depth invariant) and the mid-review expansion you requested, this is one for a human to sign off on.

Extended reasoning...

Overview

This PR changes the Bun.Image pipeline to preserve 16-bit-per-channel precision through decode → encode when no u8-only operation intervenes. It touches 8 files: the libspng PNG codec (codec_png.zig), the shared codec layer (codecs.zig — new Decoded.bit_depth field, downconvertTo8() in-place narrowing, probe() and default_max_pixels guard adjustments), the pipeline driver (Image.zig — four new downconvertTo8() call sites gating u8-only consumers), the macOS CoreGraphics C++ shim (image_coregraphics_shim.cpp — new dlsym'd CFNumberGetValue/CFDictionaryGetValue/CGImageSourceCopyPropertiesAtIndex/kCGImagePropertyDepth, an extra ABI parameter on bun_coregraphics_decode, and a new 16-bpc vImage VFmt path), the macOS Zig wrapper (backend_coregraphics.zig), the Windows WIC backend (backend_wic.zigGetPixelFormat vtable slot retyped, a 15-entry high-bpc GUID allowlist, 64bppRGBA convert target), the contributor README, and ~400 lines of tests including hand-rolled 16-bpc PNG and baseline-TIFF binary fixtures.

Security risks

The change is security-adjacent: it modifies the decompression-bomb maxPixels guard in three places (codec_png.decode, codecs.probe, and both system backends) to halve the effective pixel budget for 16-bpc sources. Earlier review rounds caught and fixed a u64 overflow in the probe() formulation that would have panicked Debug/ReleaseSafe builds on a 25-byte hostile IHDR, and a 2× regression in the worst-case allocation cap. Those are now fixed with regression tests, and the current formulation (w*h > max_pixels/2) is overflow-safe. downconvertTo8() does an align(1) u16 pointer-cast over a heap buffer and an in-place shrinking realloc; the buffer is always allocator-owned and even-length, so this looks correct, but it is the kind of pointer-reinterpretation a human should glance at. No injection, auth, or data-exposure surface.

Level of scrutiny

High. This is not a mechanical change: it rewrites a documented pipeline invariant ("RGBA8 everywhere" → "RGBA8 except the 16-bpc carry-through"), adds an ABI parameter to a C++↔Zig boundary, retypes a COM vtable slot, and depends on undocumented-in-tests behaviour of OS codecs (vImage 16-bpc widening, WIC's HEIF extension pixel-format reporting, WIC TIFF strictness on Server 2019). The scope was expanded mid-review at Jarred-Sumner's explicit request ("can you fix it properly?") from PNG-only to HEIC/AVIF/TIFF across CG and WIC, and that expansion briefly broke all three Windows CI lanes before being fixed. The README now publicly promises "iPhone HEIC 10-bit → PNG 16" on Windows, which is untested against a real HEIC fixture (only synthetic TIFF is exercised).

Other factors

Test coverage is thorough for the PNG path (11 cases including round-trip byte-equality, downconvert-on-op, iCCP payload survival, the maxPixels halving with an 8-bpc control, and the hostile-IHDR overflow regression). System-backend coverage is weaker by necessity — the TIFF tests pass-vacuously on hosts where WIC rejects the hand-rolled fixture, and HEIC/AVIF 10-bit is untested. CI at 1be2728 shows image.test.ts green on all lanes; remaining failures (s3, http-abort, hot.test) are unrelated. All ~12 inline review comments from me and CodeRabbit across six rounds have been addressed and resolved. Given a maintainer was actively shaping scope here, they should review the final result rather than have it bot-approved.

@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI at sha d6440bd: same WTF AtomStringImpl::wasRemoved flake as build 53169 — fetch-http2-client.test.ts on linux-x64-asan. This is a recurring WebKit-layer race (concurrent HTTP/2 fetch tests interning strings). My diff touches src/runtime/image/ and src/jsc/bindings/image_*.cpp — zero overlap with WebKit atom tables or HTTP/2.

I already used my one ci: retrigger at ca4c7f74 for this same flake on build 53169, so per policy I'm not pushing another.

Final state of the diff:

  • Windows image tests: green on all 3 Windows lanes after 1be2728
  • Linux image tests: 106 pass / 5 skip / 0 fail
  • All review threads resolved
  • Diff is green on every lane that reports on it

Needs a maintainer to re-roll the ASAN lane or merge.

@robobun
robobun force-pushed the farm/c2a27d0a/image-16bpc-png branch from d6440bd to 6699303 Compare June 6, 2026 00:06
@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main, which migrated the repo from Zig to Rust since this PR branched. The conflict resolution was effectively a rewrite:

  • The 9 Zig-era commits patched src/runtime/image/*.zig, which are now dead porting-reference files on main. Those edits are dropped; the .zig files are untouched relative to main.
  • The whole fix is ported 1:1 to the Rust pipeline instead, squashed to one commit (6699303): codecs.rs (Decoded.bit_depth + downconvert_to_8() + probe halving + encode threading), codec_png.rs (SPNG_FMT_RGBA16 decode, bit_depth encode, maxPixels halving), Image.rs (narrow before ops / placeholder / non-PNG encode), backend_coregraphics.rs (out_bit_depth param), backend_wic.rs (GetPixelFormat vtable slot + 15-GUID high-bpc classifier + 64bppRGBA target).
  • image_coregraphics_shim.cpp changes (kCGImagePropertyDepth probe, VFmt{16,64} path) re-applied; this file survived the migration with only minor edits.
  • Tests and README carried over unchanged in content (README wording adapted to the Rust fn names).

Verification on the rebase: full image suite green on Linux (106 pass / 5 skip), adversarial + kernels + sharp-comparison suites green (127 pass), cargo cross-target check green on all 10 targets, and the gate holds — with src/ reverted to main the 16-bpc tests fail 6/16, with the port they pass 16/16 (3 skipped on Linux).

@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build 60945 (sha 6699303, the Rust port), final state now that the build has finished:

test/js/bun/image/image.test.ts passed on every shard that executed it, including darwin 14 x64 (first run of the CoreGraphics 16-bpc path on real hardware) and the Linux and Windows shards. Every failure in the build is unrelated to this diff:

  • bunx.test.ts "should handle package that requires node 24" fails on all 13 platforms that ran it. @angular/cli@latest is now 22.0.0 with engines.node: "^22.22.3 || ^24.15.0 || >=26.0.0", and Bun self-reports Node v24.3.0, so ng refuses to start and exits 3. This reproduces with stock bun 1.4.0 on a clean machine and fails identically on unrelated PR builds (for example 60931). Fixes are already open: Upgrade reported Node.js version to 26.3.0 #31818 (bump the reported Node version) and test: pin @angular/cli version in bunx node-version test #31820 (pin @angular/cli in the test).
  • mysql2.test.ts on one alpine x64 shard: "A functional docker is required in CI for some tests" (docker daemon down on that runner).
  • bun-install-registry.test.ts peer-hoisting case on windows 11 aarch64: expected a-dep@1.0.1, got 1.0.9. That test family is already annotated test.todoIf(isFlaky && ...) in-tree.
  • One darwin 14 x64 shard: "buildkite-agent artifact download timed out after 120s", zero tests executed. The sibling darwin 14 x64 shard passed, and it is the one that ran the image suite.
  • The darwin 26/14 aarch64 test jobs expired in the runner queue without ever starting.

No retrigger pushed: the bunx failure is deterministic registry state and will reproduce on every run until #31818 or #31820 lands. This PR is ready for review; CI should go green once one of those merges.

Comment thread src/jsc/bindings/image_coregraphics_shim.cpp
Comment thread src/jsc/bindings/image_coregraphics_shim.cpp
Comment thread src/jsc/bindings/image_coregraphics_shim.cpp
Comment thread src/jsc/bindings/image_coregraphics_shim.cpp
Comment thread src/runtime/image/Image.rs
Comment thread src/runtime/image/backend_coregraphics.rs
Comment thread src/runtime/image/backend_wic.rs
Comment thread src/runtime/image/backend_wic.rs
Comment thread src/runtime/image/backend_wic.rs
Comment thread src/runtime/image/backend_wic.rs
Comment thread src/runtime/image/backend_wic.rs
Comment thread src/runtime/image/backend_wic.rs
Comment thread src/runtime/image/codec_png.rs
Comment thread src/runtime/image/codec_png.rs
Comment thread src/runtime/image/codec_png.rs
Comment thread src/runtime/image/codec_png.rs
Comment thread src/runtime/image/codecs.rs
Comment thread src/runtime/image/codecs.rs
Comment thread src/runtime/image/codecs.rs
Comment thread src/runtime/image/codecs.rs
Comment thread src/runtime/image/codecs.rs
Comment thread src/runtime/image/codecs.rs

@claude claude Bot 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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/jsc/bindings/image_coregraphics_shim.cpp:208-209 — These two newly-added comments cross-reference src/runtime/image/codecs.zig (line 208) and src/runtime/image/codec_png.zig (line 353), but both files were deleted in the Zig→Rust migration this PR was rebased across — the referenced Decoded doc and the PNG halving now live in codecs.rs and codec_png.rs. Should be .rs in both places.

    Extended reasoning...

    What the issue is

    Two comments this PR adds to src/jsc/bindings/image_coregraphics_shim.cpp reference Zig source files that no longer exist in the tree:

    • Line 208 (comment on kBunCGBitmapByteOrder16Host): "See src/runtime/image/codecs.zig's Decoded doc."
    • Line 353 (comment on effective_max_pixels halving): "same as the PNG halving in src/runtime/image/codec_png.zig."

    ls src/runtime/image/*.zig returns nothing — the .zig files were deleted during the repo-wide Zig→Rust migration that landed on main between this PR's original May branch point and its June 6 rebase. The Decoded struct doc-comment and the max_pixels-halving logic these comments point at now live in src/runtime/image/codecs.rs (the bit_depth field doc at ~line 220) and src/runtime/image/codec_png.rs (the effective_max_pixels block at ~line 140) respectively.

    Why this happened

    The PR's own timeline explains the mismatch. Per the 2026-06-06 rebase comment:

    The 9 Zig-era commits patched src/runtime/image/*.zig, which are now dead porting-reference files on main. Those edits are dropped; the .zig files are untouched relative to main. The whole fix is ported 1:1 to the Rust pipeline instead … image_coregraphics_shim.cpp changes … re-applied; this file survived the migration with only minor edits.

    So the C++ shim's edits were carried over from the Zig-era commits with the comment text unchanged, while the Rust files they now describe were written fresh — leaving the cross-references pointing at the pre-migration paths. Since then main has removed the .zig porting-reference files entirely, so the paths now resolve to nothing at all.

    Step-by-step proof

    1. grep -n '\.zig' src/jsc/bindings/image_coregraphics_shim.cpp yields exactly two hits: line 208 (codecs.zig) and line 353 (codec_png.zig). Both are in + hunks of this PR's diff — they are new lines, not pre-existing.
    2. ls src/runtime/image/*.zig → no matches. The referenced files do not exist on the current tree.
    3. The content the comments describe does exist, in the .rs equivalents:
      • src/runtime/image/codecs.rs defines struct Decoded with the bit_depth: u8 field and its host-endian-u16 doc-comment — exactly what line 208 wants the reader to look at.
      • src/runtime/image/codec_png.rs::decode() computes effective_max_pixels = if ihdr.bit_depth == 16 { max_pixels / 2 } else { max_pixels } — the "PNG halving" line 353 refers to.
    4. The PR's own June-6 rebase comment states the .zig files are "dead porting-reference files" and that the fix was ported to codecs.rs / codec_png.rs, confirming the .rs paths are the intended targets.

    Why existing code doesn't prevent it

    Nothing enforces that comment cross-references resolve to real files; the C++ compiler doesn't see them and there's no lint for it. The shim was one of the few files that survived the migration mostly-unchanged, so its comments were re-applied verbatim from the pre-rebase commits without the .zig → .rs substitution the rest of the port received.

    Impact

    Documentation only — zero runtime impact. A reader following either "See …" reference lands on a nonexistent path and has to grep for the .rs equivalent themselves. Flagging because REVIEW.md's "Only comment what the code cannot say" section covers comment accuracy, and this PR has consistently accepted and fixed comment-accuracy nits of exactly this kind (resolved threads on the codecs.zig doc wording, the WIC comment tightening, and the TIFF inline-value prose).

    How to fix

    - // without a byte swap. See src/runtime/image/codecs.zig's Decoded doc.
    + // without a byte swap. See src/runtime/image/codecs.rs's Decoded doc.
    -     // halving in src/runtime/image/codec_png.zig.
    +     // halving in src/runtime/image/codec_png.rs.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Both stale .zig cross-references were already fixed in f4fd484 (the comment-trim commit): the shim now points at src/runtime/image/codecs.rs (line 197) and src/runtime/image/codec_png.rs (line 327), and grep -n "\.zig" src/jsc/bindings/image_coregraphics_shim.cpp comes back empty. The review ran against the pre-trim commit 048ff6a.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

CI state at f4fd484 (build 96265): the only failure is test/bake/deinitialization.test.ts segfaulting (null deref) on the windows 2019 x64 lane. The identical crash appears on unrelated PR builds 96285, 96276 and 96222, so it is a break on main, now reported for triage separately. The image suite passed on every lane that ran it; this PR does not touch bake. Diff is ready for review.

Comment thread src/runtime/image/backend_wic.rs
Comment thread src/runtime/image/backend_wic.rs

@claude claude Bot 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.

I reviewed this PR after 49fbe79 and didn't find any new bugs — the grayscale WIC GUIDs and test were added as suggested, and all earlier threads are resolved. Given the scope (three-backend FFI/ABI change to bun_coregraphics_decode, new WIC vtable slot + 20-GUID classifier, a new bit_depth invariant threaded through every decode/encode/op site), a human sign-off is still worthwhile.

What was reviewed:

  • downconvert_to_8 call-site coverage — every u8-only consumer (ops, apply_orientation, ThumbHash, non-PNG/indexed encode) narrows before use.
  • The halved max_pixels guard is applied consistently across codec_png::decode, probe(), the CG shim, and WIC, using the overflow-safe w*h > cap/2 form.
  • CG shim: TOCTOU guard extended to bit_depth; kCGBitmapByteOrder16Host matches libspng's host-endian RGBA16; non-Apple stub arity updated.
  • WIC: GUID_WICPixelFormat64bppRGBA is the straight-alpha 0x16 variant; GetPixelFormat vtable slot position matches IWICBitmapSource.
Extended reasoning...

Overview

This PR adds 16-bit-per-channel preservation to Bun.Image: libspng decode/encode now honours SPNG_FMT_RGBA16 for 16-bpc IHDRs, codecs::Decoded gains a bit_depth field with a downconvert_to_8() narrowing helper, and both system backends (macOS CoreGraphics via the C++ shim, Windows WIC) were extended to emit RGBA16 for high-bit-depth TIFF/HEIC/AVIF sources. The pipeline in Image.rs narrows to 8 bpc before any u8-only op (rotate/flip/resize/modulate), before EXIF auto-orient, before ThumbHash, and before any non-PNG-truecolour encode. ~400 lines of new tests cover the PNG round-trip, every downconvert site, the halved max-pixels guard, and hand-rolled TIFF fixtures for the CG/WIC paths. Twelve files touched.

Security risks

The change is security-adjacent because it doubles bytes-per-pixel for a class of inputs: an unchanged pixel-count guard would let a hostile 16-bpc IHDR trigger ~2× the previous worst-case allocation. This was raised in an earlier round and is now addressed uniformly — codec_png::decode, codecs::probe(), the CG shim, and WIC all halve the effective pixel budget for 16-bpc sources, using w*h > max_pixels/2 (two u32 factors, no overflow) rather than w*h*2 > max_pixels. The CG shim's phase-1/phase-2 TOCTOU guard was extended to compare bit_depth as well as dimensions, so a mid-decode swap from 8→16 bpc can't overflow the caller-sized buffer. No new attack surface beyond that.

Level of scrutiny

High. This is a cross-cutting invariant change with platform-specific FFI on all three OSes: a new parameter on the bun_coregraphics_decode C ABI (with the non-Apple stub updated in lockstep), a newly-typed COM vtable slot (GetPixelFormat), a 20-entry hand-transcribed WIC GUID allowlist, and dlsym'd CoreFoundation/ImageIO symbols. Every existing decode/encode/op path now has an implicit bit_depth precondition. The failure modes for a missed downconvert_to_8() call site are buffer-size mismatches into C image kernels. That warrants a maintainer's eyes even with no automated findings.

Other factors

The PR has been through many review rounds — I raised roughly a dozen issues over prior iterations (unreachable error paths, the 2× allocation guard, a u64 overflow in probe(), vacuous test constants, stale README/doc comments, WIC GUID label/value mismatches, missing R10G10B10A2 and grayscale GUIDs, Windows CI failures, the runTiffTest vacuous-pass reporting) and all are resolved in the current diff. Two maintainers (190n, Jarred-Sumner) have engaged on direction but neither has approved. CI at f4fd484 was green on the image suite across all lanes; the sole failure was an unrelated bake/deinitialization.test.ts crash on Windows also present on main. One comment-cop bot flag on backend_wic.rs:833 (comment length) remains open but is cosmetic. Given the size and platform reach, deferring for human approval rather than auto-approving.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 49fbe79 (build 96345): 177 jobs passed, zero test failures. The build is marked failed only because the darwin 14 aarch64 test jobs expired in the runner queue three times (original plus two auto-retries) without ever starting; no code ran on that lane. The image suite, including the new 16-bpc grayscale TIFF test, is green on every lane that executed it, and the same suite passed on darwin 14 x64 in the previous build. Ready for review.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun.Image limits PNGs to 8 bits per channel

3 participants