From 048ff6a6c31ba1caadee77b1b38ff14fe85ec23d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:53:55 +0000 Subject: [PATCH 1/3] Bun.Image: preserve 16-bit-per-channel sources through decode/encode Rebased onto main's Rust migration: the original Zig implementation of this fix (9 commits on the pre-migration branch) is ported 1:1 to the Rust image pipeline; the .zig reference files are untouched. PNG (codec_png.rs): - decode picks SPNG_FMT_RGBA16 when the IHDR says 16 and reports bit_depth on codecs::Decoded; the max_pixels budget halves for 16-bpc sources so the ~1 GiB byte cap is depth-independent - encode takes bit_depth and writes it to the IHDR; libspng converts host-endian u16 to PNG big-endian on write OS codecs: - CoreGraphics shim gains CGImageSourceCopyPropertiesAtIndex + kCGImagePropertyDepth in the dlsym table; sources with reported depth >= 9 (HEIC 10/12, TIFF 16) render via VFmt{16, 64, kCGImageAlphaLast | ByteOrder16Host}; bun_coregraphics_decode gains an out_bit_depth param (non-Apple stub updated) - WIC backend retypes the GetPixelFormat vtable slot and classifies the source GUID against a 15-entry high-bpc allowlist (48/64bpp families, Half/FixedPoint, R10G10B10A2 HDR10 packed); matches convert to 64bppRGBA instead of 32bppRGBA Pipeline (Image.rs / codecs.rs): - codecs::Decoded gains bit_depth (manual Default = 8) and downconvert_to_8(), an in-place high-byte narrow - apply_pipeline / apply_orientation / placeholder / non-PNG encode narrow to 8 bpc first; PNG-truecolour passthrough with no ops is the only path that stays 16 - probe() halves the pixel budget for 16-bpc PNG IHDRs in the overflow-safe form (w*h > max_pixels/2) Tests are unchanged from the Zig-era branch: 16 cases covering the round-trip, low-byte preservation, downconvert-on-op, iCCP payload equality, the maxPixels halving with an 8-bpc control, the hostile IHDR overflow guard, and the system-backend TIFF fixtures (mac/win). Closes #30462 --- src/jsc/bindings/image_coregraphics_shim.cpp | 95 +++- src/runtime/image/Image.rs | 36 +- src/runtime/image/README.md | 18 +- src/runtime/image/backend_coregraphics.rs | 20 +- src/runtime/image/backend_wic.rs | 141 +++++- src/runtime/image/codec_bmp.rs | 1 + src/runtime/image/codec_gif.rs | 1 + src/runtime/image/codec_jpeg.rs | 1 + src/runtime/image/codec_png.rs | 58 ++- src/runtime/image/codec_webp.rs | 1 + src/runtime/image/codecs.rs | 100 +++- test/js/bun/image/image.test.ts | 500 +++++++++++++++++++ 12 files changed, 922 insertions(+), 50 deletions(-) diff --git a/src/jsc/bindings/image_coregraphics_shim.cpp b/src/jsc/bindings/image_coregraphics_shim.cpp index da2242c4ade5..4a06b533a10a 100644 --- a/src/jsc/bindings/image_coregraphics_shim.cpp +++ b/src/jsc/bindings/image_coregraphics_shim.cpp @@ -70,7 +70,14 @@ struct Syms { const uint8_t* (*CFDataGetBytePtr)(CFRef); CFRef (*CFStringCreateWithCString)(CFRef, const char*, uint32_t); CFRef (*CFNumberCreate)(CFRef, int, const void*); + // CFNumberGetValue is `Boolean (*)(CFNumberRef, CFNumberType, void *out)`; + // `bool` here matches Apple's `Boolean` (a typedef for unsigned char, but + // ABI-equivalent for 0/1 returns). + bool (*CFNumberGetValue)(CFRef, int, void*); CFRef (*CFDictionaryCreate)(CFRef, const void**, const void**, long, const void*, const void*); + // CFDictionaryGetValue returns a borrowed `const void*` that lives as long + // as the enclosing dictionary; no release needed at use site. + const void* (*CFDictionaryGetValue)(CFRef, const void*); // CoreGraphics CFRef (*CGColorSpaceCreateDeviceRGB)(); void (*CGColorSpaceRelease)(CFRef); @@ -83,6 +90,11 @@ struct Syms { // ImageIO CFRef (*CGImageSourceCreateWithData)(CFRef, CFRef); CFRef (*CGImageSourceCreateImageAtIndex)(CFRef, size_t, CFRef); + // Reads the ImageIO-parsed properties dict for frame N. Used by the + // 16-bpc path to pull `kCGImagePropertyDepth` after phase 1 so phase 2 + // can size its output buffer for RGBA16 when the source warrants it + // (issue #30462). The returned dict is +1-retained and must be CFReleased. + CFRef (*CGImageSourceCopyPropertiesAtIndex)(CFRef, size_t, CFRef); CFRef (*CGImageDestinationCreateWithData)(CFRef, CFRef, size_t, CFRef); void (*CGImageDestinationAddImage)(CFRef, CFRef, CFRef); bool (*CGImageDestinationFinalize)(CFRef); @@ -96,6 +108,11 @@ struct Syms { // address and dereference at use-site). CFRef* kCFAllocatorNull; CFRef* kCGImageDestinationLossyCompressionQuality; + // `kCGImagePropertyDepth` is the dict key for ImageIO's reported bits-per- + // sample (CFNumber, SInt32). Reports the container's native depth (8/10/ + // 12/16), NOT the CGImage's render depth — we map depth≥9 → request 16 + // bpc from vImage so 10/12-bit HEIC and 16-bit TIFF keep precision. + CFRef* kCGImagePropertyDepth; const void* kCFTypeDictionaryKeyCallBacks; const void* kCFTypeDictionaryValueCallBacks; }; @@ -117,7 +134,9 @@ constexpr struct { SYM(CFDataGetBytePtr), SYM(CFStringCreateWithCString), SYM(CFNumberCreate), + SYM(CFNumberGetValue), SYM(CFDictionaryCreate), + SYM(CFDictionaryGetValue), SYM(CGColorSpaceCreateDeviceRGB), SYM(CGColorSpaceRelease), SYM(CGImageCreate), @@ -128,6 +147,7 @@ constexpr struct { SYM(CGDataProviderRelease), SYM(CGImageSourceCreateWithData), SYM(CGImageSourceCreateImageAtIndex), + SYM(CGImageSourceCopyPropertiesAtIndex), SYM(CGImageDestinationCreateWithData), SYM(CGImageDestinationAddImage), SYM(CGImageDestinationFinalize), @@ -138,6 +158,7 @@ constexpr struct { SYM(vImageVerticalReflect_ARGB8888), SYM(kCFAllocatorNull), SYM(kCGImageDestinationLossyCompressionQuality), + SYM(kCGImagePropertyDepth), SYM(kCFTypeDictionaryKeyCallBacks), SYM(kCFTypeDictionaryValueCallBacks), }; @@ -178,6 +199,14 @@ const Syms* load() constexpr uint32_t kBunCGImageAlphaLast = 3; // straight RGBA, A in byte 3 constexpr uint32_t kBunCFStringEncodingUTF8 = 0x08000100; constexpr int kBunCFNumberDoubleType = 13; +constexpr int kBunCFNumberSInt32Type = 3; +// CGBitmapInfo byte-order field for 16-bit samples. On Apple's shipping +// architectures (arm64, x86_64) host order is little-endian; using the +// explicit Little constant keeps this correct under Rosetta and matches +// what libspng's SPNG_FMT_RGBA16 writes into `Decoded.rgba` — so a 16-bpc +// TIFF / HEIC decoded via this path round-trips through PNG 16-bpc encode +// without a byte swap. See src/runtime/image/codecs.zig's Decoded doc. +constexpr uint32_t kBunCGBitmapByteOrder16Host = 1u << 12; // 0x1000 // vImage_Flags — values copied verbatim from ; // keep them in sync, the kvImageNoAllocate one used to be wrong (4 vs 512) // and silently turned every CG decode into 0xAA garbage in debug builds. @@ -256,12 +285,19 @@ enum : int32_t { CG_OK = 0, CG_ENCODE_FAILED = 3, CG_TOO_MANY_PIXELS = 4 }; -// Decode `bytes[0..len)` into a caller-allocated RGBA8 buffer. -// Two-phase: pass `out=nullptr` to get dimensions; then call again with a -// buffer of `w*h*4` to fill it. Avoids allocating in C++ so the caller owns +// Decode `bytes[0..len)` into a caller-allocated RGBA buffer. Two-phase: +// pass `out=nullptr` to get dimensions (and, via `*out_bit_depth`, whether +// to allocate for 8-bpc or 16-bpc); then call again with a buffer of +// `w*h*bpp/8` to fill it. Avoids allocating in C++ so the caller owns // the buffer like every other decode path. +// +// `*out_bit_depth` is 8 or 16 after phase 1: ImageIO's reported source depth +// (kCGImagePropertyDepth) drives it — any source ≥ 9 bpc (HEIC 10/12, TIFF +// 16) maps to 16 so the extra precision survives through to the PNG 16-bpc +// encoder added in issue #30462. Sources that don't expose depth (rare +// corrupt containers) fall back to 8. int32_t bun_coregraphics_decode(const uint8_t* bytes, size_t len, uint64_t max_pixels, - uint32_t* out_w, uint32_t* out_h, uint8_t* out) + uint32_t* out_w, uint32_t* out_h, uint8_t* out_bit_depth, uint8_t* out) { auto s = load(); if (!s) return CG_UNAVAILABLE; @@ -289,17 +325,46 @@ int32_t bun_coregraphics_decode(const uint8_t* bytes, size_t len, uint64_t max_p size_t w = s->CGImageGetWidth(r.img); size_t h = s->CGImageGetHeight(r.img); if (w == 0 || h == 0) return CG_DECODE_FAILED; - if (static_cast(w) * h > max_pixels) return CG_TOO_MANY_PIXELS; + // Probe source bit depth via ImageIO's properties dict. Only phase 1 + // needs it (phase 2 reads the caller-provided `*out_bit_depth`), but + // reading here unifies the code path — the properties dict is + // essentially free on an already-parsed CGImageSource. + uint32_t bit_depth = 8; + { + CFRef props = s->CGImageSourceCopyPropertiesAtIndex(r.src, 0, nullptr); + if (props) { + const void* v = s->CFDictionaryGetValue(props, *s->kCGImagePropertyDepth); + if (v) { + int32_t raw = 0; + if (s->CFNumberGetValue(reinterpret_cast(const_cast(v)), kBunCFNumberSInt32Type, &raw)) { + // Promote anything > 8-bpc to 16 — vImage widens 10/12-bit + // samples into the u16 MSBs via left-shift, preserving all + // source precision without quantisation. + if (raw >= 9) bit_depth = 16; + } + } + s->CFRelease(props); + } + } + // `max_pixels` is a byte budget in disguise (see codec_png.decode for + // the full rationale) — 16-bpc doubles bytes-per-pixel, so halve the + // effective pixel cap when we're about to ask vImage for RGBA16. Keeps + // the byte cap constant regardless of source depth, same as the PNG + // halving in src/runtime/image/codec_png.zig. + const uint64_t effective_max_pixels = (bit_depth == 16) ? (max_pixels / 2) : max_pixels; + if (static_cast(w) * h > effective_max_pixels) return CG_TOO_MANY_PIXELS; if (!out) { *out_w = static_cast(w); *out_h = static_cast(h); + *out_bit_depth = static_cast(bit_depth); return CG_OK; // dimensions-only probe } // TOCTOU guard: the input is a borrowed-but-mutable JS slice and this runs - // on a WorkPool thread, so JS could rewrite it with a *larger* image - // between the size probe and this render. The caller's `out` is sized for - // *out_w × *out_h from phase 1; refuse to draw past it. - if (w != *out_w || h != *out_h) return CG_DECODE_FAILED; + // on a WorkPool thread, so JS could rewrite it with a *larger* image (or + // one that reports a different bit depth) between the size probe and this + // render. Phase 2 trusts phase 1's dims / bit_depth for the output buffer + // size; refuse to draw past it. + if (w != *out_w || h != *out_h || bit_depth != *out_bit_depth) return CG_DECODE_FAILED; r.cs = s->CGColorSpaceCreateDeviceRGB(); if (!r.cs) return CG_UNAVAILABLE; @@ -307,8 +372,14 @@ int32_t bun_coregraphics_decode(const uint8_t* bytes, size_t len, uint64_t max_p // non-premultiplied alpha, which CGBitmapContext refuses — so the result // is straight RGBA with no premul→unpremul quantisation. kvImageNoAllocate // makes it write into the caller's bun.default_allocator buffer. - VBuf buf { out, h, w, w * 4 }; - VFmt fmt { 8, 32, r.cs, kBunCGImageAlphaLast, 0, nullptr, 0 }; + // + // 16-bpc path: bitsPerComponent=16, bitsPerPixel=64, and kBunCGBitmapByte + // Order16Host so the u16 samples land host-endian — matches what libspng + // SPNG_FMT_RGBA16 writes, so the pipeline's Decoded.rgba is uniform. + const uint32_t bpp = bit_depth == 16 ? 64 : 32; + const uint32_t bitmap_info = kBunCGImageAlphaLast | (bit_depth == 16 ? kBunCGBitmapByteOrder16Host : 0u); + VBuf buf { out, h, w, w * (bpp / 8) }; + VFmt fmt { bit_depth, bpp, r.cs, bitmap_info, 0, nullptr, 0 }; auto rc = s->vImageBuffer_InitWithCGImage(&buf, &fmt, nullptr, r.img, kBunVImageNoAllocate); // The contract is that kvImageNoAllocate honours buf.data exactly, but be // defensive: an OS that ignored the flag would set buf.data to its own @@ -555,7 +626,7 @@ int64_t bun_coregraphics_clipboard_change_count() #else // Non-Apple: stubs so the link succeeds; callers only reference these on // macOS so they're dead code, but LTO needs the definitions. -extern "C" int bun_coregraphics_decode(const void*, unsigned long, unsigned long long, void*, void*, void*) { return 1; } +extern "C" int bun_coregraphics_decode(const void*, unsigned long, unsigned long long, void*, void*, void*, void*) { return 1; } extern "C" int bun_coregraphics_encode(const void*, unsigned, unsigned, int, int, void*, void*) { return 1; } extern "C" int bun_coregraphics_scale(const void*, unsigned, unsigned, void*, unsigned, unsigned) { return 1; } extern "C" int bun_coregraphics_rotate90(const void*, unsigned, unsigned, void*, unsigned) { return 1; } diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 5ddad10102cd..0a713603ae7a 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -1697,6 +1697,10 @@ impl PipelineTask { } if matches!(self.kind, Kind::Placeholder) { + // ThumbHash operates on 8-bit RGBA (the hash encoder indexes + // the buffer as u8); `apply_pipeline` is also 8-bpc-only, so + // a 16-bpc source must narrow first. + decoded.downconvert_to_8(); self.result = match make_placeholder(&decoded.rgba, decoded.width, decoded.height) { Ok(r) => r, Err(e) => TaskResult::Err(e), @@ -1732,12 +1736,24 @@ impl PipelineTask { // the profile reinterprets a non-sRGB source (Display-P3, Adobe RGB, // Jpegli XYB) as sRGB and visibly shifts the colours — see #30197. // JPEG/PNG/WebP embed it; HEIC/AVIF via the system backend do not. + // 16-bpc survives only on the PNG-truecolour path — JPEG/WebP/HEIC/ + // AVIF and indexed-PNG encoders are all u8-only. Narrow here so the + // codec arms never see a mismatched buffer. Issue #30462. + if enc.format != codecs::Format::Png || enc.palette { + decoded.downconvert_to_8(); + } if enc.icc_profile.is_none() { // `EncodeOptions.icc_profile` borrows for the duration of `encode()` // (raw `NonNull<[u8]>`); `decoded` outlives the call below. enc.icc_profile = decoded.icc_profile.as_deref().map(core::ptr::NonNull::from); } - let out = match codecs::encode(&decoded.rgba, decoded.width, decoded.height, enc) { + let out = match codecs::encode( + &decoded.rgba, + decoded.width, + decoded.height, + decoded.bit_depth, + enc, + ) { Ok(o) => o, Err(e) => { self.result = TaskResult::Err(e); @@ -1944,6 +1960,15 @@ impl PipelineTask { /// the profile survives unchanged. fn apply_pipeline(&self, d: &mut codecs::Decoded) -> Result<(), codecs::Error> { let p = &self.pipeline; + // The geometry kernels (rotate/flip/resize) and the modulate pass + // are u8-only. Narrow 16-bpc RGBA to 8 before any op runs. No-op + // when all pipeline slots are empty, which preserves the + // 16-bpc PNG→PNG pass-through from issue #30462. + let has_op = + p.rotate != 0 || p.flip || p.flop || p.resize.is_some() || p.modulate.is_some(); + if has_op { + d.downconvert_to_8(); + } if p.rotate != 0 { let next = codecs::rotate(&d.rgba, d.width, d.height, u32::from(p.rotate))?; // Assignment drops @@ -2017,7 +2042,7 @@ fn make_placeholder(rgba: &[u8], sw: u32, sh: u32) -> Result Result<(), codecs::Error> { let t = orient.transform(); + // Same as apply_pipeline — the kernels are u8-only. Reached only + // from the JPEG auto-orient path today, and JPEGs are always + // 8-bpc, but narrow unconditionally so a future non-JPEG EXIF + // path can't skip it. + if t.flip || t.flop || t.rotate != 0 { + d.downconvert_to_8(); + } if t.flip { let next = codecs::flip(&d.rgba, d.width, d.height, false)?; d.rgba = next; diff --git a/src/runtime/image/README.md b/src/runtime/image/README.md index 293b2b8a1497..69b6fa306cda 100644 --- a/src/runtime/image/README.md +++ b/src/runtime/image/README.md @@ -29,7 +29,9 @@ The codecs themselves are vendored via `scripts/build/deps/{libjpeg-turbo,libspn 1. Add a field to `Pipeline` in `Image.rs` (one slot per op — setters overwrite, there is no op list) and a stage in `PipelineTask.applyPipeline` at the right point in the fixed `rotate → flip/flop → resize → modulate` - order. + order. OR the new slot's non-empty-ness into the `has_op` disjunction at + the top of `apply_pipeline` so a 16-bpc input is narrowed to 8 bpc before + your kernel runs — the geometry/modulate kernels are u8-only. 2. Add a `do` method that parses args, writes the slot, returns `callframe.this()`. 3. Add it to `proto:` in `Image.classes.ts`. @@ -46,8 +48,18 @@ The codecs themselves are vendored via `scripts/build/deps/{libjpeg-turbo,libspn ## Invariants -- Pixel format is **RGBA8 everywhere** between decode and encode. Decoders are - configured to emit it; encoders are fed it. Nothing branches on channels. +- Pixel format is **RGBA8 everywhere** between decode and encode, with one + exception: libspng emits RGBA16 for 16-bpc PNG sources, CoreGraphics (mac) + emits RGBA16 for HEIC/AVIF/TIFF sources with ImageIO-reported depth ≥ 9, + and WIC (Windows) emits 64bppRGBA when the source's native pixel format + carries > 8 bpc — so high-bit-depth round-trips (PNG 16 ↔ PNG 16, TIFF 16 + → PNG 16, iPhone HEIC 10-bit → PNG 16) survive at full precision (issue + #30462). `Decoded.bit_depth` tracks 8 vs 16 (10/12-bit sources are widened + to 16 by the OS codec); every op and every non-PNG-truecolour encoder is + u8-only, so `apply_pipeline` / `apply_orientation` call + `Decoded::downconvert_to_8()` before they run, and the encode step does + the same before any non-PNG encode. JPEG/WebP/BMP/GIF decoders always + emit RGBA8, so those paths don't branch on channels. - **Decode** output is `bun.default_allocator`-owned `[]u8`. **Encode** output is `Encoded{bytes, free}` where `free` is the _codec's_ deallocator (`tj3Free`/`WebPFree`/`std.c.free`/`mi_free`); `then()` hands that buffer diff --git a/src/runtime/image/backend_coregraphics.rs b/src/runtime/image/backend_coregraphics.rs index 21e6cce2d6a9..506442b7efbe 100644 --- a/src/runtime/image/backend_coregraphics.rs +++ b/src/runtime/image/backend_coregraphics.rs @@ -69,6 +69,11 @@ unsafe extern "C" { max_pixels: u64, out_w: *mut u32, out_h: *mut u32, + // Phase 1 writes 8 or 16 (driven by `kCGImagePropertyDepth`); phase 2 + // reads it to size the VFmt/VBuf for either RGBA8 or RGBA16. Any + // source with depth ≥ 9 (HEIC 10/12, TIFF 16) maps to 16 so the + // extra precision survives through to PNG 16-bpc encode. #30462. + out_bit_depth: *mut u8, out: *mut u8, // nullable ) -> i32; @@ -103,8 +108,11 @@ fn map_err(rc: i32) -> BackendError { pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result { let mut w: u32 = 0; let mut h: u32 = 0; - // Phase 1: dimensions only (out=null) so we can allocate in the global - // allocator like every other decode path. + let mut bit_depth: u8 = 8; + // Phase 1: dimensions + source depth (out=null) so we can allocate in + // the global allocator like every other decode path and size the buffer + // for either RGBA8 (4 B/px) or RGBA16 (8 B/px) before asking vImage to + // render into it. // SAFETY: bytes is a valid slice; out=null signals "probe only" to the shim. match unsafe { bun_coregraphics_decode( @@ -113,18 +121,20 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result {} rc => return Err(map_err(rc)), } + let bytes_per_pixel: usize = if bit_depth == 16 { 8 } else { 4 }; // PERF: vec![0u8; n] zero-fills — profile if hot. - let mut out = vec![0u8; (w as usize) * (h as usize) * 4]; + let mut out = vec![0u8; (w as usize) * (h as usize) * bytes_per_pixel]; // Phase 2: render. The C side re-creates the CGImageSource (cheap — the // header parse is the only repeated work) so we don't have to thread an // opaque handle across the boundary. - // SAFETY: out has exactly w*h*4 bytes; shim writes that many. + // SAFETY: out has exactly w*h*bytes_per_pixel bytes; shim writes that many. match unsafe { bun_coregraphics_decode( bytes.as_ptr(), @@ -132,6 +142,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result Result Result max_pixels { + + // Inspect the source's native pixel format. If it carries > 8 bpc + // (TIFF-16, HEIC 10/12, AVIF 10/12, HDR10 packed), ask WIC to convert + // to 64bppRGBA so the precision survives through to PNG 16-bpc encode. + // Otherwise stay on the 32bppRGBA fast path. Issue #30462. + let mut src_pf = GUID_WICPixelFormat32bppRGBA; + if frame.get_pixel_format(&mut src_pf) < 0 { + return Err(DecodeFailed); + } + let want_16 = is_high_bit_depth_source(&src_pf); + // `max_pixels` is a byte budget in disguise (see codec_png::decode); + // halve the pixel cap when we're about to allocate 8 B/pixel so the + // byte cap stays constant regardless of source depth, same as the PNG + // halving. + let effective_max_pixels: u64 = if want_16 { max_pixels / 2 } else { max_pixels }; + if (w as u64) * (h as u64) > effective_max_pixels { return Err(TooManyPixels); } - // WIC frames come in whatever pixel format the codec emits; normalise to - // straight-alpha RGBA8 in one hop. let convert_fn = wicConvertBitmapSource .get() .copied() .ok_or(BackendUnavailable)?; + let dst_pf: &GUID = if want_16 { + &GUID_WICPixelFormat64bppRGBA + } else { + &GUID_WICPixelFormat32bppRGBA + }; let mut conv: *mut IWICBitmapSource = ptr::null_mut(); // SAFETY: convert_fn resolved from windowscodecs.dll; frame is non-null. - if unsafe { convert_fn(&GUID_WICPixelFormat32bppRGBA, frame.as_ptr(), &mut conv) } < 0 { + if unsafe { convert_fn(dst_pf, frame.as_ptr(), &mut conv) } < 0 { return Err(DecodeFailed); } let conv = ComPtr::new(conv).ok_or(DecodeFailed)?; @@ -135,8 +153,10 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result u32::MAX as u64 { @@ -154,11 +174,12 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result { unsafe { ((*(*self.as_ptr()).vt).GetSize)(self.as_ptr(), w, h) } } #[inline] + fn get_pixel_format(self, out: &mut GUID) -> HRESULT { + unsafe { ((*(*self.as_ptr()).vt).GetPixelFormat)(self.as_ptr(), out) } + } + #[inline] fn copy_pixels(self, rc: *const c_void, stride: u32, size: u32, out: *mut u8) -> HRESULT { unsafe { ((*(*self.as_ptr()).vt).CopyPixels)(self.as_ptr(), rc, stride, size, out) } } @@ -703,7 +728,12 @@ struct IWICBitmapSource { struct IWICBitmapSourceVTable { unk: IUnknownVTable, GetSize: unsafe extern "system" fn(*mut IWICBitmapSource, *mut u32, *mut u32) -> HRESULT, - GetPixelFormat: *const c_void, + // Reports the source's native WIC pixel format GUID. Used by the + // 16-bpc path (issue #30462) to pick between `32bppRGBA` and + // `64bppRGBA` for the convert step so TIFF-16 / HEIC-10 / AVIF-12 + // don't silently downcast to 8-bpc in `WICConvertBitmapSource`. + // Cheap — the header was already parsed by CreateDecoderFromStream. + GetPixelFormat: unsafe extern "system" fn(*mut IWICBitmapSource, *mut GUID) -> HRESULT, GetResolution: *const c_void, CopyPalette: *const c_void, CopyPixels: unsafe extern "system" fn( @@ -783,6 +813,101 @@ const GUID_WICPixelFormat32bppRGBA: GUID = GUID { d3: 0x43dd, d4: [0xa7, 0xa8, 0xa2, 0x99, 0x35, 0x26, 0x1a, 0xe9], }; +/// 16-bit-per-channel RGBA, host-endian u16. WIC widens 10/12-bit HDR sources +/// (HEIC/AVIF) and preserves 16-bit sources (TIFF) losslessly when asked for +/// this target. Straight-alpha (not the "PRGBA" premultiplied variant at +/// `…c9, 0x17`, which would quantise through the normal pipeline ops). Layout +/// matches libspng's SPNG_FMT_RGBA16 so a `TIFF 16 → PNG 16` round-trip is +/// bit-identical without a byte swap. Issue #30462. +const GUID_WICPixelFormat64bppRGBA: GUID = GUID { + d1: 0x6fddc324, + d2: 0x4e03, + d3: 0x4bfe, + d4: [0xb1, 0x85, 0x3d, 0x77, 0x76, 0x8d, 0xc9, 0x16], +}; + +/// Most WIC pixel formats share the `{6fddc324-4e03-4bfe-b185-3d77768dc9XX}` +/// family and differ only in the final byte. +const fn wic_pf(suffix: u8) -> GUID { + GUID { + d1: 0x6fddc324, + d2: 0x4e03, + d3: 0x4bfe, + d4: [0xb1, 0x85, 0x3d, 0x77, 0x76, 0x8d, 0xc9, suffix], + } +} + +/// Source pixel formats that carry > 8-bit-per-channel precision. Listed +/// explicitly so a future WIC-native format doesn't silently fall back to +/// 8-bpc: adding a new GUID here is the only change needed to preserve its +/// depth. The "Half"/"Float"/"FixedPoint" families are included because +/// WICConvertBitmapSource widens them to u16 RGBA (the float-to-int +/// conversion is `clamp(0..1) * 0xFFFF` in WIC's reference converter). +/// Covers TIFF-16 (48bppRGB), HEIC/AVIF 10/12/16-bit (48bpp or 64bpp +/// flavours), and the 32bppR10G10B10A2 / HDR10 packed-10-bit formats that +/// the Microsoft HEIF Image Extension may emit for HEVC Main10 content. +const HIGH_BPC_SOURCES: [GUID; 15] = [ + // 48bppRGB / 48bppBGR — no-alpha 16-bpc (common TIFF variants). + wic_pf(0x15), + GUID { + d1: 0xe605a384, + d2: 0xb468, + d3: 0x46ce, + d4: [0xbb, 0x2e, 0x36, 0xf1, 0x80, 0xe6, 0x43, 0x13], + }, + // 64bppRGBA (straight & premultiplied) + 64bppBGRA. + wic_pf(0x16), + wic_pf(0x17), + GUID { + d1: 0x1562ff7c, + d2: 0xd352, + d3: 0x46f9, + d4: [0x97, 0x9e, 0x42, 0x97, 0x6b, 0x79, 0x22, 0x46], + }, + // 64bppRGB — 16-bpc no-alpha. + GUID { + d1: 0xa1182111, + d2: 0x186d, + d3: 0x4d42, + d4: [0xbc, 0x6a, 0x9c, 0x83, 0x03, 0xa8, 0xdf, 0xf9], + }, + // 48bppRGBHalf / 48bppRGBFixedPoint — emitted by HDR TIFF encoders. + wic_pf(0x3b), + wic_pf(0x12), + // 64bppRGBHalf / 64bppRGBAHalf / 64bppRGBAFixedPoint / + // 64bppRGBFixedPoint / 128bppRGBFixedPoint. The 64bpp family is the + // normal HDR TIFF / high-bit-depth output; 128bpp is listed because + // WICConvertBitmapSource narrows it to 64bppRGBA correctly (u32/f32 + // channels → clamped u16) so the carry-through works uniformly even + // for 32-bit-per-channel sources. + wic_pf(0x42), + wic_pf(0x3a), + wic_pf(0x1d), + wic_pf(0x40), + wic_pf(0x41), + // 32bppR10G10B10A2 / 32bppR10G10B10A2HDR10 — 10-bit samples packed into + // a 32-bit DWORD. The HEIF Image Extension can emit either for Main10 + // HEVC / 10-bit AVIF depending on the source's BT.2020 vs sRGB primaries. + // WICConvertBitmapSource scales 10-bit → 16-bit losslessly (the default + // converter does `value * 0xFFFF / 0x3FF`), so they slot into the same + // 64bppRGBA path as the 48/64 bpp formats above. + GUID { + d1: 0x604e1bb5, + d2: 0x8a3c, + d3: 0x4b65, + d4: [0xb1, 0x1c, 0xbc, 0x0b, 0x8d, 0xd7, 0x5b, 0x7f], + }, + GUID { + d1: 0x9c215c5d, + d2: 0x1acc, + d3: 0x4f0e, + d4: [0xa4, 0xbc, 0x70, 0xfb, 0x3a, 0xe8, 0xfd, 0x28], + }, +]; + +fn is_high_bit_depth_source(g: &GUID) -> bool { + HIGH_BPC_SOURCES.iter().any(|h| h == g) +} const GUID_ContainerFormatJpeg: GUID = GUID { d1: 0x19e4a5aa, d2: 0x5662, diff --git a/src/runtime/image/codec_bmp.rs b/src/runtime/image/codec_bmp.rs index 78804cc8edbf..f9e221820657 100644 --- a/src/runtime/image/codec_bmp.rs +++ b/src/runtime/image/codec_bmp.rs @@ -188,6 +188,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result Result Result) { let _ = unsafe { spng_set_iccp(ctx, &raw const iccp) }; } +/// `bit_depth` is 8 or 16. 16-bpc input must be host-endian u16 RGBA — +/// `SPNG_FMT_PNG` tells libspng to convert to PNG's big-endian wire format +/// itself (`to_bigendian` flag set when `ihdr.bit_depth == 16`). Everything +/// else — JPEG/WebP/indexed-PNG encode, and the geometry kernels — is +/// u8-only; the caller in Image.rs downconverts first. Issue #30462. pub(crate) fn encode( rgba: &[u8], w: u32, h: u32, + bit_depth: u8, level: i8, icc_profile: Option<&[u8]>, ) -> Result { + // Programming error if the caller passed an unexpected depth — the + // internal pipeline only produces 8 or 16. A runtime reject here keeps + // a future caller from silently writing a malformed IHDR. + if bit_depth != 8 && bit_depth != 16 { + return Err(codecs::Error::EncodeFailed); + } // SAFETY: spng_ctx_new is safe to call; null return = OOM. let ctx = unsafe { spng_ctx_new(SPNG_CTX_ENCODER) }; if ctx.is_null() { @@ -223,7 +255,7 @@ pub(crate) fn encode( let ihdr = Ihdr { width: w, height: h, - bit_depth: 8, + bit_depth, color_type: SPNG_COLOR_TYPE_TRUECOLOR_ALPHA, ..Default::default() }; diff --git a/src/runtime/image/codec_webp.rs b/src/runtime/image/codec_webp.rs index 6296196d7a3a..2afae0fc6799 100644 --- a/src/runtime/image/codec_webp.rs +++ b/src/runtime/image/codec_webp.rs @@ -219,6 +219,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result, // global allocator (mimalloc) pub(crate) width: u32, pub(crate) height: u32, + /// Bits per channel in `rgba`: 8 (one byte per channel, `width*height*4` + /// bytes) or 16 (two host-endian bytes per channel, `width*height*8` + /// bytes). Set to 16 by libspng's 16-bpc PNG decode path, by the + /// CoreGraphics backend for any HEIC/AVIF/TIFF source whose ImageIO- + /// reported depth is ≥ 9, and by the WIC backend when the source's + /// native pixel-format GUID carries > 8 bpc (48/64 bpp families plus + /// the packed 10-bit HDR10 formats). Every other decoder produces 8. + /// Geometry kernels and non-PNG-truecolour encoders are u8-only, so + /// the pipeline calls `downconvert_to_8` before any op or non-PNG + /// encode — high-bit-depth source → PNG truecolour with no ops is + /// the only path that stays at 16. Issue #30462. + pub(crate) bit_depth: u8, /// ICC color profile bytes pulled from the source container (JPEG APP2, /// PNG iCCP, WebP ICCP), global-allocator-owned. `None` when the /// source didn't carry one or the decode path doesn't extract it — @@ -226,6 +244,44 @@ pub struct Decoded { pub(crate) icc_profile: Option>, } +impl Default for Decoded { + fn default() -> Self { + Self { + rgba: Vec::new(), + width: 0, + height: 0, + bit_depth: 8, + icc_profile: None, + } + } +} + +impl Decoded { + /// Convert `rgba` from 16-bpc host-endian to 8-bpc in place, narrowing + /// each u16 channel to the high byte (equivalent to `>> 8`). A no-op + /// when `bit_depth` is already 8. Called before any transform (the + /// geometry kernels are u8-only) and before non-PNG encode (JPEG/WebP + /// are 8-bpc formats). The buffer is truncated so the tail memory is + /// released on the next realloc; `shrink_to_fit` keeps peak RSS at + /// one frame. + pub fn downconvert_to_8(&mut self) { + if self.bit_depth != 16 { + return; + } + let samples = (self.width as usize) * (self.height as usize) * 4; + // Narrow by keeping the high byte — same convention as every + // 16→8 PNG down-converter (libpng `png_set_strip_16`, libvips). + // The buffer holds host-endian u16 samples. + for i in 0..samples { + let v = u16::from_ne_bytes([self.rgba[2 * i], self.rgba[2 * i + 1]]); + self.rgba[i] = (v >> 8) as u8; + } + self.rgba.truncate(samples); + self.rgba.shrink_to_fit(); + self.bit_depth = 8; + } +} + #[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error, strum::IntoStaticStr)] pub enum Error { #[error("UnknownFormat")] @@ -250,7 +306,11 @@ pub enum Error { bun_core::oom_from_alloc!(Error); /// Sharp's default: 0x3FFF * 0x3FFF ≈ 268 MP. A single RGBA8 frame at this -/// cap is ~1 GiB, which is already past where you'd want to be. +/// cap is ~1 GiB, which is already past where you'd want to be. 16-bpc +/// decode (issue #30462) doubles bytes-per-pixel, so the guards in +/// `codec_png::decode`, `probe` and the system backends halve the +/// effective pixel budget for 16-bpc sources to keep the byte cap at +/// that same ~1 GiB regardless of source depth. pub(crate) const DEFAULT_MAX_PIXELS: u64 = 0x3FFF * 0x3FFF; /// Hint from the pipeline about the eventual output size. JPEG can do M/8 @@ -347,12 +407,26 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result { let h: u32; match fmt { Format::Png => { - // sig(8) · IHDR{len(4) type(4) w(4) h(4) ...} - if bytes.len() < 24 { + // sig(8) · IHDR{len(4) type(4) w(4) h(4) bit_depth(1) ...} + if bytes.len() < 25 { return Err(Error::DecodeFailed); } w = u32::from_be_bytes(bytes[16..20].try_into().expect("infallible: size matches")); h = u32::from_be_bytes(bytes[20..24].try_into().expect("infallible: size matches")); + // 16-bpc PNG decode allocates 8 bytes/pixel instead of 4, so + // the `max_pixels` byte budget (documented ~1 GiB at the cap) + // has to halve to stay consistent. Keep probe() in lockstep + // with codec_png::decode()'s guard so `.metadata()` and + // `.bytes()` agree on what's too big. Issue #30462. + // + // Divide the budget rather than multiplying the pixel count — + // `w` and `h` are unvalidated u32 here (the i32 range reject + // runs *after* the match), so `w * h * 2` can overflow u64 + // on a hostile 25-byte IHDR. Two u32 factors always fit in + // u64, and `max_pixels / 2` can't overflow either. + if bytes[24] == 16 && (w as u64) * (h as u64) > max_pixels / 2 { + return Err(Error::TooManyPixels); + } } Format::Jpeg => { // turbojpeg's header decode is already cheap (no scan data read). @@ -533,10 +607,16 @@ impl Encoded { } } +/// `bit_depth` is 8 or 16. Only PNG truecolour encode honours 16; everything +/// else expects 8-bit RGBA. The pipeline in Image.rs downconverts before +/// calling in, so a 16 here on a non-PNG path is a programming error — but +/// the codec arms still assume `rgba.len() == w*h*4` and would miscompute, +/// so keep the precondition in the caller, not a runtime check here. pub(crate) fn encode( rgba: &[u8], width: u32, height: u32, + bit_depth: u8, opts: EncodeOptions, ) -> Result { // SAFETY: `EncodeOptions.icc_profile` is borrowed from the caller for the @@ -548,6 +628,8 @@ pub(crate) fn encode( // operates on raw RGB numbers without converting colour spaces, so // the palette entries are still in the source space and need the // profile to be interpreted correctly (see PNG spec §11.3.3.3). + // Indexed PNGs are always 8 bpc (palette entries are u8), so the + // caller must have downconverted before choosing the indexed path. Format::Png => { if opts.palette { png::encode_indexed( @@ -560,7 +642,7 @@ pub(crate) fn encode( icc, ) } else { - png::encode(rgba, width, height, opts.compression_level, icc) + png::encode(rgba, width, height, bit_depth, opts.compression_level, icc) } } Format::Webp => webp::encode(rgba, width, height, opts.quality, opts.lossless, icc), @@ -727,6 +809,7 @@ pub(crate) fn rotate(src: &[u8], w: u32, h: u32, degrees: u32) -> Result Result [number, number, number, number], +): Uint8Array { + const ihdr = new Uint8Array(13); + const iv = new DataView(ihdr.buffer); + iv.setUint32(0, width); + iv.setUint32(4, height); + ihdr[8] = 16; // bit depth + ihdr[9] = 6; // color type = RGBA + // Each row: filter byte + width*8 bytes (4 channels × 2 bytes, big-endian). + const raw = new Uint8Array(height * (1 + width * 8)); + for (let y = 0; y < height; y++) { + const row = y * (1 + width * 8); + raw[row] = 0; + for (let x = 0; x < width; x++) { + const [r, g, b, a] = pixelOf(x, y); + const p = row + 1 + x * 8; + raw[p] = r >> 8; + raw[p + 1] = r & 0xff; + raw[p + 2] = g >> 8; + raw[p + 3] = g & 0xff; + raw[p + 4] = b >> 8; + raw[p + 5] = b & 0xff; + raw[p + 6] = a >> 8; + raw[p + 7] = a & 0xff; + } + } + const idat = zlib.deflateSync(raw); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", ihdr), + pngChunk("IDAT", idat), + pngChunk("IEND", new Uint8Array(0)), + ]); +} + +// Extract the IHDR bit_depth byte from a PNG. The 8 bytes of signature are +// followed immediately by IHDR; bit_depth is the 9th byte of the IHDR data +// section, i.e. offset 8 (sig) + 8 (chunk header) + 8 (width+height) = 24. +function pngBitDepth(png: Uint8Array): number { + return png[24]; +} + +// Minimal 16-bpc RGBA PNG decoder. Same defilter as `decodePngRaw` but the +// unit is 2 bytes per sample (stride = w*8), and samples stay big-endian as +// the file stores them — the test compares individual u16 channels, not +// raw bytes, so endianness is handled at the read site. +function decodePngRaw16(png: Uint8Array): { w: number; h: number; data: Uint8Array } { + const dv = new DataView(png.buffer, png.byteOffset, png.byteLength); + let off = 8; + let w = 0; + let h = 0; + const idats: Uint8Array[] = []; + while (off < png.length) { + const len = dv.getUint32(off); + const type = String.fromCharCode(png[off + 4], png[off + 5], png[off + 6], png[off + 7]); + const data = png.subarray(off + 8, off + 8 + len); + if (type === "IHDR") { + w = dv.getUint32(off + 8); + h = dv.getUint32(off + 12); + } else if (type === "IDAT") { + idats.push(data); + } else if (type === "IEND") break; + off += 12 + len; + } + const raw = zlib.inflateSync(Buffer.concat(idats)); + // PNG filter unit for 16-bpc RGBA is 8 bytes (the "bpp" in filter-speak). + const bpp = 8; + const stride = w * bpp; + const out = new Uint8Array(w * h * bpp); + let p = 0; + for (let y = 0; y < h; y++) { + const f = raw[p++]; + const rowOut = y * stride; + const prevOut = (y - 1) * stride; + for (let i = 0; i < stride; i++) { + const x = raw[p++]; + const a = i >= bpp ? out[rowOut + i - bpp] : 0; + const b = y > 0 ? out[prevOut + i] : 0; + const c = y > 0 && i >= bpp ? out[prevOut + i - bpp] : 0; + let v = x; + if (f === 1) v = (x + a) & 255; + else if (f === 2) v = (x + b) & 255; + else if (f === 3) v = (x + ((a + b) >> 1)) & 255; + else if (f === 4) { + const pp = a + b - c; + const pa = Math.abs(pp - a); + const pb = Math.abs(pp - b); + const pc = Math.abs(pp - c); + v = (x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255; + } + out[rowOut + i] = v; + } + } + return { w, h, data: out }; +} + +// Read one 16-bpc RGBA pixel as four u16 big-endian channels. +function rgba16At(buf: Uint8Array, w: number, x: number, y: number): [number, number, number, number] { + const i = (y * w + x) * 8; + return [ + (buf[i] << 8) | buf[i + 1], + (buf[i + 2] << 8) | buf[i + 3], + (buf[i + 4] << 8) | buf[i + 5], + (buf[i + 6] << 8) | buf[i + 7], + ]; +} + // Minimal PNG decoder for 8-bit RGBA non-interlaced (the only kind we emit). function decodePngRaw(png: Uint8Array): { w: number; h: number; data: Uint8Array } { const dv = new DataView(png.buffer, png.byteOffset, png.byteLength); @@ -478,6 +593,391 @@ describe("Bun.Image", () => { }); }); + // 16-bit-per-channel PNG preservation — issue #30462. Before the fix, every + // PNG decode went through SPNG_FMT_RGBA8 and every encode hard-coded + // bit_depth = 8 in the IHDR, so a 16-bpc source was silently truncated to + // 8 bpc even on a no-op PNG → PNG pass-through. The pipeline now keeps the + // 16-bpc buffer through decode → encode when no ops are requested and + // downconverts to 8 only when a u8-only op (resize/rotate/flip/modulate) + // or a u8-only encoder (JPEG/WebP/indexed PNG) is in the chain. + describe("16-bit-per-channel PNG (issue #30462)", () => { + // Four corner pixels at full 16-bit precision. High/low bytes differ so a + // truncation to 8 bpc (which keeps only the high byte) is detectable. + // (0,0)=red (3,0)=green (0,2)=blue (3,2)=white-ish + const corners16: Array<[number, number, number, number]> = [ + [0xffff, 0x0123, 0x0234, 0xffff], // deep red with non-zero low bytes in G/B + [0x0123, 0xffff, 0x0456, 0xffff], + [0x0789, 0x1234, 0xffff, 0xa5a5], // partial alpha, non-trivial low byte + [0xfedc, 0xdcba, 0xcdef, 0xffff], + ]; + const cornersPng16 = makePng16(4, 3, (x, y) => { + if (y === 0 && x === 0) return corners16[0]; + if (y === 0 && x === 3) return corners16[1]; + if (y === 2 && x === 0) return corners16[2]; + if (y === 2 && x === 3) return corners16[3]; + return [0x8080, 0x8080, 0x8080, 0xffff]; + }); + + test("source IHDR advertises bit_depth = 16 (fixture sanity)", () => { + expect(pngBitDepth(cornersPng16)).toBe(16); + }); + + test("PNG 16 → PNG no-op keeps bit_depth = 16 in the output IHDR", async () => { + const out = await new Bun.Image(cornersPng16).png().bytes(); + expect(pngBitDepth(out)).toBe(16); + }); + + test("PNG 16 → PNG no-op preserves every low byte (no 8-bpc truncation)", async () => { + const out = await new Bun.Image(cornersPng16).png().bytes(); + expect(pngBitDepth(out)).toBe(16); + const { w, h, data } = decodePngRaw16(out); + expect({ w, h }).toEqual({ w: 4, h: 3 }); + expect(rgba16At(data, w, 0, 0)).toEqual(corners16[0]); + expect(rgba16At(data, w, 3, 0)).toEqual(corners16[1]); + expect(rgba16At(data, w, 0, 2)).toEqual(corners16[2]); + expect(rgba16At(data, w, 3, 2)).toEqual(corners16[3]); + }); + + test("PNG 16 → .bytes() with no format setter re-encodes as 16-bpc PNG", async () => { + // `.bytes()` with no `.png()`/`.jpeg()`/`.webp()` call re-encodes in + // the source format; for a PNG source that's PNG, and the 16-bpc + // IHDR must carry through. + const out = await new Bun.Image(cornersPng16).bytes(); + expect(pngBitDepth(out)).toBe(16); + }); + + test("PNG 16 → write(file) lands a 16-bpc PNG on disk", async () => { + using dir = tempDir("image-16bpc-write", {}); + const p = join(String(dir), "out.png"); + await new Bun.Image(cornersPng16).write(p); + const bytes = new Uint8Array(await Bun.file(p).bytes()); + expect(pngBitDepth(bytes)).toBe(16); + }); + + test("PNG 16 → resize(...) downconverts to 8-bpc (geometry kernels are u8-only)", async () => { + // Any op touches the u8 kernels, so the output has to be 8-bpc. This + // is the acceptable lossy path — the test locks in that we DO emit + // valid 8-bpc output rather than a corrupted 16-bpc buffer. + const out = await new Bun.Image(cornersPng16).resize(2, 2).png().bytes(); + expect(pngBitDepth(out)).toBe(8); + const { w, h } = decodePngRaw(out); + expect({ w, h }).toEqual({ w: 2, h: 2 }); + }); + + test("PNG 16 → rotate(90) downconverts to 8-bpc and rotates correctly", async () => { + const out = await new Bun.Image(cornersPng16).rotate(90).png().bytes(); + expect(pngBitDepth(out)).toBe(8); + const { w, h, data } = decodePngRaw(out); + expect({ w, h }).toEqual({ w: 3, h: 4 }); + // Red (0xffff,0x0123,0x0234,0xffff) narrows to the high byte per + // channel: [0xff, 0x01, 0x02, 0xff]. After 90° CW the source (0,0) + // lands at dst (h-1, 0) = (2, 0). + expect(rgbaAt(data, w, 2, 0)).toEqual([0xff, 0x01, 0x02, 0xff]); + }); + + test("PNG 16 → .jpeg() downconverts to 8-bpc (JPEG is 8-bpc-only)", async () => { + // JPEG decode produces 8-bpc regardless, so we're checking the encode + // path — the decoded 16-bpc buffer must narrow before libjpeg-turbo + // sees it, otherwise the encoder misreads 2× the bytes and produces + // a malformed stream (or writes random memory). + const out = await new Bun.Image(cornersPng16).jpeg({ quality: 90 }).bytes(); + expect(out[0]).toBe(0xff); + expect(out[1]).toBe(0xd8); + // Round-trip through PNG to check dimensions survived. + const back = await new Bun.Image(out).png().bytes(); + const { w, h } = decodePngRaw(back); + expect({ w, h }).toEqual({ w: 4, h: 3 }); + }); + + test("PNG 16 → .webp({lossless}) downconverts to 8-bpc (VP8L is 8-bpc-only)", async () => { + const out = await new Bun.Image(cornersPng16).webp({ lossless: true }).bytes(); + expect(String.fromCharCode(out[0], out[1], out[2], out[3])).toBe("RIFF"); + expect(String.fromCharCode(out[8], out[9], out[10], out[11])).toBe("WEBP"); + }); + + test("PNG 16 → png({palette}) downconverts to 8-bpc (quantise is u8-only)", async () => { + const out = await new Bun.Image(cornersPng16).png({ palette: true, colors: 16 }).bytes(); + // Indexed PNG: IHDR.color_type = 3 (palette). bit_depth here is the + // palette index width (1/2/4/8), NOT channel precision — our encoder + // currently emits 8 even when 4 indices would fit, but lock in only + // that the result is a valid indexed PNG at ≤8-bit index width so a + // future packing optimisation doesn't spuriously break the test. + expect(out[25]).toBe(3); // IHDR byte 25 = color_type + expect(pngBitDepth(out)).toBeLessThanOrEqual(8); + }); + + test("PNG 16 round-trip preserves iCCP (profile survives across 16-bpc decode+encode)", async () => { + // Regression for #30197 interacting with the 16-bpc path: the profile + // must carry through the new RGBA16 decode arm exactly as it does + // through the RGBA8 arm, byte-for-byte — not just the chunk frame. + const profile = new Uint8Array(256); + for (let i = 0; i < profile.length; i++) profile[i] = (i * 7 + 11) & 0xff; + // Splice an iCCP chunk right after IHDR (same layout as the ICC tests + // below). The chunk body is: keyword + 0x00 separator + compression + // method byte + deflate(profile). + const compressed = zlib.deflateSync(profile); + const body = Buffer.concat([Buffer.from("ICC Profile", "latin1"), Buffer.from([0, 0]), compressed]); + const iccp = pngChunk("iCCP", body); + // IHDR ends at byte 8 + 8 + 13 + 4 = 33. Splice iCCP there. + const iccpPng = Buffer.concat([cornersPng16.subarray(0, 33), iccp, cornersPng16.subarray(33)]); + const out = await new Bun.Image(iccpPng).png().bytes(); + expect(pngBitDepth(out)).toBe(16); + // Locate the output's iCCP chunk and inflate its payload — asserting + // chunk presence only would miss a regression that drops/truncates + // the profile bytes while still emitting the chunk header. + const dv = new DataView(out.buffer, out.byteOffset, out.byteLength); + let off = 8; + let decoded: Uint8Array | null = null; + while (off + 12 <= out.length) { + const len = dv.getUint32(off); + const type = String.fromCharCode(out[off + 4], out[off + 5], out[off + 6], out[off + 7]); + if (type === "iCCP") { + // iCCP body = Latin-1 keyword + NUL + compression_method byte + zlib. + const chunkBody = out.subarray(off + 8, off + 8 + len); + const nul = chunkBody.indexOf(0); + // Skip keyword + NUL + 1-byte compression method → rest is zlib. + decoded = new Uint8Array(zlib.inflateSync(Buffer.from(chunkBody.subarray(nul + 2)))); + break; + } + if (type === "IEND") break; + off += 12 + len; + } + expect(decoded).not.toBeNull(); + expect(decoded).toEqual(profile); + }); + + // Decompression-bomb guard — 16-bpc doubles bytes-per-pixel, so the + // pixel budget halves to keep the byte cap bounded. A hostile IHDR + // that flips bit_depth 8→16 on an otherwise-accepted pixel count must + // reject before any allocation runs. + test("maxPixels budget halves for 16-bpc sources (decode rejects before allocating)", async () => { + // The budget must sit strictly inside (pixels, 2*pixels) so the + // halving is the deciding factor — equivalently, pixels must sit + // in (max_pixels/2, max_pixels]. Otherwise a future refactor that + // drops the halving would still reject via the base guard and the + // test would pass silently. + // + // 4096×4096 = 16,777,216 pixels. With maxPixels = 20_000_000: + // • regular guard: 16.7M > 20M → false, accepts + // • halved (16-bpc): 16.7M > 10M → true, rejects + // so a 16-bpc source rejects ONLY because of the halving. + const pixels = 4096 * 4096; // 16,777,216 + const budget = 20_000_000; // ∈ (pixels, 2*pixels) = (16.7M, 33.5M) + // Build tiny 16-bpc and 8-bpc fixtures, patch both to 4096×4096. + // The 8-bpc fixture is the control — same dimensions, same budget, + // but the halving shouldn't apply, so it must ACCEPT where the + // 16-bpc one rejects. This is what makes the test prove halving + // is doing the work. + const patch4k = (buf: Uint8Array) => { + const dv = new DataView(buf.buffer, buf.byteOffset); + dv.setUint32(16, 4096); + dv.setUint32(20, 4096); + // IHDR chunk-data spans bytes 12..28 inclusive; CRC at byte 29. + let c = ~0 >>> 0; + for (let i = 12; i < 29; i++) { + c ^= buf[i]; + for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); + } + dv.setUint32(29, ~c >>> 0); + }; + const bomb16 = makePng16(1, 1, () => [0, 0, 0, 0xffff]); + patch4k(bomb16); + const bomb8 = makePng(1, 1, () => [0, 0, 0, 255]); + patch4k(bomb8); + // `metadata()` uses `probe()`, which must apply the same 16-bpc + // halving — otherwise `.metadata()` accepts and `.bytes()` rejects. + await expect(new Bun.Image(bomb16, { maxPixels: budget }).metadata()).rejects.toThrow(/maxPixels/); + await expect(new Bun.Image(bomb16, { maxPixels: budget }).bytes()).rejects.toThrow(/maxPixels/); + // Control: same dimensions, same budget, but 8-bpc. If the halving + // were wrongly applied here (or applied unconditionally), this + // would reject too — which is how we know the halving is scoped + // to 16-bpc only. + const meta8 = await new Bun.Image(bomb8, { maxPixels: budget }).metadata(); + expect(meta8).toEqual({ width: 4096, height: 4096, format: "png" }); + // And bumping the 16-bpc budget to 2×pixels + 1 clears the halved + // cap: ⌊33,554,433 / 2⌋ = 16,777,216, so the halved comparison + // (w*h > max_pixels/2) is 16,777,216 > 16,777,216 — strictly false. + const meta16 = await new Bun.Image(bomb16, { maxPixels: pixels * 2 + 1 }).metadata(); + expect(meta16).toEqual({ width: 4096, height: 4096, format: "png" }); + }); + + // Overflow guard on the 16-bpc probe — `w` and `h` are unvalidated u32 + // at the point of the guard check, so a hostile IHDR with both set to + // 0xFFFFFFFF and bit_depth=16 would, under the naive `w*h*2` form, + // overflow u64 and panic the JS thread in Debug / ReleaseSafe builds + // before the later i32 range reject runs. `w*h > max_pixels/2` is the + // same inequality in overflow-safe form — two u32 factors always fit + // in u64. + test("probe() rejects a hostile max-dimensions 16-bpc IHDR without overflowing", async () => { + // Minimal synthetic 25-byte "PNG": 8-byte signature + 17 arbitrary + // bytes such that bytes[16..20] = bytes[20..24] = 0xFFFFFFFF and + // bytes[24] = 16. The IHDR chunk type/length/CRC aren't validated + // here, so the bytes in between don't need to be well-formed — the + // probe only cares about sig + the width/height/bit_depth offsets. + const buf = new Uint8Array(25); + buf.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + // bytes[16..20] = width = 0xFFFFFFFF + for (let i = 16; i < 24; i++) buf[i] = 0xff; + buf[24] = 16; // bit_depth + // Must throw, not panic. The rejection can be TooManyPixels (the + // new guard) or DecodeFailed (the i32 range check) — both are + // acceptable outcomes; the failure mode this test guards against + // is an integer-overflow panic. + await expect(new Bun.Image(buf).metadata()).rejects.toThrow(); + }); + + // Hand-roll a TIFF 6.0 "Baseline RGB" (class R) image at `bits`-per- + // sample (8 or 16). The 12 baseline-required tags are all present + // (ImageWidth, ImageLength, BitsPerSample, Compression=1, Photometric + // Interpretation=2, StripOffsets, SamplesPerPixel, RowsPerStrip, + // StripByteCounts, XResolution, YResolution, ResolutionUnit) — WIC + // in particular is strict about the Resolution tags and rejects the + // file without them. II/little-endian throughout. TIFF routes through + // the system backend on macOS (CoreGraphics) and Windows (WIC); Linux + // returns UnsupportedOnPlatform so the bit_depth plumbing there is + // a no-op. + function makeTiff(w: number, h: number, bits: 8 | 16, pixelOf: (x: number, y: number) => [number, number, number]) { + const bytesPerSample = bits / 8; + const strip = new Uint8Array(w * h * 3 * bytesPerSample); + const sv = new DataView(strip.buffer); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const [r, g, b] = pixelOf(x, y); + const off = (y * w + x) * 3 * bytesPerSample; + if (bits === 16) { + sv.setUint16(off, r, true); + sv.setUint16(off + 2, g, true); + sv.setUint16(off + 4, b, true); + } else { + strip[off] = r; + strip[off + 1] = g; + strip[off + 2] = b; + } + } + // External-data region follows the IFD. Contents (in emission order): + // - BitsPerSample: 3 u16s (6 bytes) + // - XResolution: 2 u32s = 1 RATIONAL (8 bytes) + // - YResolution: 2 u32s = 1 RATIONAL (8 bytes) + const bitsOff = 8 + 2 + 12 * 12 + 4; // after header + IFD count + 12 tags + next-IFD u32 + const xResOff = bitsOff + 6; + const yResOff = xResOff + 8; + const stripOff = yResOff + 8; + const total = stripOff + strip.length; + const buf = new Uint8Array(total); + const dv = new DataView(buf.buffer); + buf[0] = 0x49; + buf[1] = 0x49; // 'II' + dv.setUint16(2, 42, true); + dv.setUint32(4, 8, true); + dv.setUint16(8, 12, true); // IFD entry count + let p = 10; + const tag = (id: number, type: number, count: number, value: number) => { + dv.setUint16(p, id, true); + dv.setUint16(p + 2, type, true); + dv.setUint32(p + 4, count, true); + dv.setUint32(p + 8, value, true); + p += 12; + }; + // Types: 3=SHORT (u16), 4=LONG (u32), 5=RATIONAL (2 u32s). + // IFD tags must be sorted ascending by tag ID. + tag(256, 4, 1, w); // ImageWidth + tag(257, 4, 1, h); // ImageLength + tag(258, 3, 3, bitsOff); // BitsPerSample — 3 u16s at external offset + tag(259, 3, 1, 1); // Compression = none + tag(262, 3, 1, 2); // PhotometricInterpretation = RGB + tag(273, 4, 1, stripOff); // StripOffsets + tag(277, 3, 1, 3); // SamplesPerPixel + tag(278, 4, 1, h); // RowsPerStrip (whole image in one strip) + tag(279, 4, 1, strip.length); // StripByteCounts + tag(282, 5, 1, xResOff); // XResolution = 72/1 → offset + tag(283, 5, 1, yResOff); // YResolution = 72/1 → offset + tag(296, 3, 1, 2); // ResolutionUnit = inch + dv.setUint32(p, 0, true); // next-IFD offset = 0 + dv.setUint16(bitsOff, bits, true); + dv.setUint16(bitsOff + 2, bits, true); + dv.setUint16(bitsOff + 4, bits, true); + dv.setUint32(xResOff, 72, true); // numerator + dv.setUint32(xResOff + 4, 1, true); // denominator + dv.setUint32(yResOff, 72, true); + dv.setUint32(yResOff + 4, 1, true); + buf.set(strip, stripOff); + return buf; + } + + // System-backend (CoreGraphics / WIC) 16-bpc plumbing. On Linux the + // system backend is absent so TIFF returns UnsupportedOnPlatform at the + // `decodeViaSystem → BackendUnavailable` fallthrough — this block only + // exercises the mac/win paths. + // + // Each test probes the system codec by decoding a minimal 1×1 TIFF + // first: macOS ImageIO always accepts it, but Windows Server 2019 + // ships with a WIC TIFF codec that rejects some otherwise-valid + // baseline TIFFs with WINCODEC_ERR_COMPONENTNOTFOUND. When the probe + // fails the test body is skipped — bun:test has no dynamic-skip + // primitive so a zero-assertion test resolves as `(pass)` in the + // reporter rather than `(skip)`; the console.warn below makes the + // opt-out visible in CI logs so a reader can tell coverage is absent + // on that lane. Production code is still exercised on the macOS and + // Windows 11 lanes. + const runTiffTest = async (body: () => Promise) => { + try { + await new Bun.Image(makeTiff(1, 1, 8, () => [0, 0, 0])).metadata(); + } catch { + console.warn("system-backend TIFF probe failed; skipping body (host WIC/CG rejects the hand-rolled fixture)"); + return; + } + await body(); + }; + + describe.skipIf(!isMacOS && !isWindows)("system backend (TIFF / HEIC)", () => { + test("TIFF 16-bpc → PNG 16-bpc (system backend reports depth ≥ 9 → widen)", async () => { + await runTiffTest(async () => { + // 2×2 with distinct u16 values whose low byte is non-zero — an + // 8-bpc downcast anywhere in the chain would clobber the low + // byte. CG uses kCGImagePropertyDepth to pick RGBA16; WIC uses + // the source GUID (48bppRGB) classifier. Both land on RGBA16. + const src = makeTiff(2, 2, 16, (x, y) => { + if (x === 0 && y === 0) return [0xffee, 0x0123, 0x0456]; + if (x === 1 && y === 0) return [0x0789, 0xfedc, 0x1234]; + if (x === 0 && y === 1) return [0xdcba, 0x5678, 0xcafe]; + return [0x2345, 0xabcd, 0x9876]; + }); + const out = await new Bun.Image(src).png().bytes(); + expect(pngBitDepth(out)).toBe(16); + const { w, h, data } = decodePngRaw16(out); + expect({ w, h }).toEqual({ w: 2, h: 2 }); + // RGB TIFF → vImage/WIC fill A=0xFFFF (no alpha channel in + // source). Neither CG nor WIC does colour-space conversion on + // an untagged RGB TIFF, so samples round-trip byte-for-byte. + expect(rgba16At(data, w, 0, 0)).toEqual([0xffee, 0x0123, 0x0456, 0xffff]); + expect(rgba16At(data, w, 1, 1)).toEqual([0x2345, 0xabcd, 0x9876, 0xffff]); + }); + }); + + test("TIFF 8-bpc → PNG 8-bpc (classifier does not upgrade 8-bpc sources)", async () => { + await runTiffTest(async () => { + // Control: the classifier must not unconditionally upgrade every + // TIFF — only sources whose native WIC pixel format / CG reported + // depth is > 8. An 8-bpc TIFF should land on the 32bppRGBA fast + // path and encode to 8-bpc PNG. + const src = makeTiff(2, 2, 8, (_x, _y) => [10, 20, 30]); + const out = await new Bun.Image(src).png().bytes(); + expect(pngBitDepth(out)).toBe(8); + }); + }); + + test("TIFF 16-bpc + resize forces downconvert to 8-bpc (geometry kernels are u8)", async () => { + await runTiffTest(async () => { + // Same rule as PNG 16 → resize: any pipeline op triggers the u8 + // narrow before the kernel runs. + const src = makeTiff(4, 4, 16, (x, y) => [(x * 0x3333) & 0xffff, (y * 0x3333) & 0xffff, 0xffff]); + const out = await new Bun.Image(src).resize(2, 2).png().bytes(); + expect(pngBitDepth(out)).toBe(8); + }); + }); + }); + }); + // ICC colour profile preservation — #30197. The RGBA pixel buffer the // pipeline works on carries no colour-space tag, so dropping the source's // ICC profile reinterprets non-sRGB inputs (Display P3, Adobe RGB, Jpegli From f4fd4849f2c14b0b4ded79eb40374699615fd300 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:54:32 +0000 Subject: [PATCH 2/3] Trim comments in the image 16-bpc paths --- src/jsc/bindings/image_coregraphics_shim.cpp | 70 ++++++------------- src/runtime/image/Image.rs | 19 ++---- src/runtime/image/backend_coregraphics.rs | 9 +-- src/runtime/image/backend_wic.rs | 57 ++++------------ src/runtime/image/codec_png.rs | 32 +++------ src/runtime/image/codecs.rs | 72 +++++--------------- 6 files changed, 68 insertions(+), 191 deletions(-) diff --git a/src/jsc/bindings/image_coregraphics_shim.cpp b/src/jsc/bindings/image_coregraphics_shim.cpp index 4a06b533a10a..92ba8a31896d 100644 --- a/src/jsc/bindings/image_coregraphics_shim.cpp +++ b/src/jsc/bindings/image_coregraphics_shim.cpp @@ -70,13 +70,10 @@ struct Syms { const uint8_t* (*CFDataGetBytePtr)(CFRef); CFRef (*CFStringCreateWithCString)(CFRef, const char*, uint32_t); CFRef (*CFNumberCreate)(CFRef, int, const void*); - // CFNumberGetValue is `Boolean (*)(CFNumberRef, CFNumberType, void *out)`; - // `bool` here matches Apple's `Boolean` (a typedef for unsigned char, but - // ABI-equivalent for 0/1 returns). + // Returns Apple's `Boolean` (unsigned char); `bool` is ABI-compatible. bool (*CFNumberGetValue)(CFRef, int, void*); CFRef (*CFDictionaryCreate)(CFRef, const void**, const void**, long, const void*, const void*); - // CFDictionaryGetValue returns a borrowed `const void*` that lives as long - // as the enclosing dictionary; no release needed at use site. + // Returns a borrowed reference; do not release. const void* (*CFDictionaryGetValue)(CFRef, const void*); // CoreGraphics CFRef (*CGColorSpaceCreateDeviceRGB)(); @@ -90,10 +87,7 @@ struct Syms { // ImageIO CFRef (*CGImageSourceCreateWithData)(CFRef, CFRef); CFRef (*CGImageSourceCreateImageAtIndex)(CFRef, size_t, CFRef); - // Reads the ImageIO-parsed properties dict for frame N. Used by the - // 16-bpc path to pull `kCGImagePropertyDepth` after phase 1 so phase 2 - // can size its output buffer for RGBA16 when the source warrants it - // (issue #30462). The returned dict is +1-retained and must be CFReleased. + // Returned dict is +1-retained; CFRelease it. CFRef (*CGImageSourceCopyPropertiesAtIndex)(CFRef, size_t, CFRef); CFRef (*CGImageDestinationCreateWithData)(CFRef, CFRef, size_t, CFRef); void (*CGImageDestinationAddImage)(CFRef, CFRef, CFRef); @@ -108,10 +102,8 @@ struct Syms { // address and dereference at use-site). CFRef* kCFAllocatorNull; CFRef* kCGImageDestinationLossyCompressionQuality; - // `kCGImagePropertyDepth` is the dict key for ImageIO's reported bits-per- - // sample (CFNumber, SInt32). Reports the container's native depth (8/10/ - // 12/16), NOT the CGImage's render depth — we map depth≥9 → request 16 - // bpc from vImage so 10/12-bit HEIC and 16-bit TIFF keep precision. + // CFNumber(SInt32): the container's native bits-per-sample (8/10/12/16), + // not the CGImage's render depth. CFRef* kCGImagePropertyDepth; const void* kCFTypeDictionaryKeyCallBacks; const void* kCFTypeDictionaryValueCallBacks; @@ -200,12 +192,9 @@ constexpr uint32_t kBunCGImageAlphaLast = 3; // straight RGBA, A in byte 3 constexpr uint32_t kBunCFStringEncodingUTF8 = 0x08000100; constexpr int kBunCFNumberDoubleType = 13; constexpr int kBunCFNumberSInt32Type = 3; -// CGBitmapInfo byte-order field for 16-bit samples. On Apple's shipping -// architectures (arm64, x86_64) host order is little-endian; using the -// explicit Little constant keeps this correct under Rosetta and matches -// what libspng's SPNG_FMT_RGBA16 writes into `Decoded.rgba` — so a 16-bpc -// TIFF / HEIC decoded via this path round-trips through PNG 16-bpc encode -// without a byte swap. See src/runtime/image/codecs.zig's Decoded doc. +// CGBitmapInfo byte-order flag: 16-bit samples in host order, matching the +// layout libspng's SPNG_FMT_RGBA16 writes (see Decoded::bit_depth in +// src/runtime/image/codecs.rs). constexpr uint32_t kBunCGBitmapByteOrder16Host = 1u << 12; // 0x1000 // vImage_Flags — values copied verbatim from ; // keep them in sync, the kvImageNoAllocate one used to be wrong (4 vs 512) @@ -286,16 +275,10 @@ enum : int32_t { CG_OK = 0, CG_TOO_MANY_PIXELS = 4 }; // Decode `bytes[0..len)` into a caller-allocated RGBA buffer. Two-phase: -// pass `out=nullptr` to get dimensions (and, via `*out_bit_depth`, whether -// to allocate for 8-bpc or 16-bpc); then call again with a buffer of -// `w*h*bpp/8` to fill it. Avoids allocating in C++ so the caller owns -// the buffer like every other decode path. -// -// `*out_bit_depth` is 8 or 16 after phase 1: ImageIO's reported source depth -// (kCGImagePropertyDepth) drives it — any source ≥ 9 bpc (HEIC 10/12, TIFF -// 16) maps to 16 so the extra precision survives through to the PNG 16-bpc -// encoder added in issue #30462. Sources that don't expose depth (rare -// corrupt containers) fall back to 8. +// pass `out=nullptr` to get dimensions and `*out_bit_depth` (8, or 16 for +// sources whose ImageIO-reported depth is >= 9); then call again with a +// buffer of `w*h*bit_depth/2` bytes. Avoids allocating in C++ so the caller +// owns the buffer like every other decode path. int32_t bun_coregraphics_decode(const uint8_t* bytes, size_t len, uint64_t max_pixels, uint32_t* out_w, uint32_t* out_h, uint8_t* out_bit_depth, uint8_t* out) { @@ -325,10 +308,6 @@ int32_t bun_coregraphics_decode(const uint8_t* bytes, size_t len, uint64_t max_p size_t w = s->CGImageGetWidth(r.img); size_t h = s->CGImageGetHeight(r.img); if (w == 0 || h == 0) return CG_DECODE_FAILED; - // Probe source bit depth via ImageIO's properties dict. Only phase 1 - // needs it (phase 2 reads the caller-provided `*out_bit_depth`), but - // reading here unifies the code path — the properties dict is - // essentially free on an already-parsed CGImageSource. uint32_t bit_depth = 8; { CFRef props = s->CGImageSourceCopyPropertiesAtIndex(r.src, 0, nullptr); @@ -337,20 +316,15 @@ int32_t bun_coregraphics_decode(const uint8_t* bytes, size_t len, uint64_t max_p if (v) { int32_t raw = 0; if (s->CFNumberGetValue(reinterpret_cast(const_cast(v)), kBunCFNumberSInt32Type, &raw)) { - // Promote anything > 8-bpc to 16 — vImage widens 10/12-bit - // samples into the u16 MSBs via left-shift, preserving all - // source precision without quantisation. + // vImage widens 10/12-bit samples to u16, so > 8 bpc maps to 16. if (raw >= 9) bit_depth = 16; } } s->CFRelease(props); } } - // `max_pixels` is a byte budget in disguise (see codec_png.decode for - // the full rationale) — 16-bpc doubles bytes-per-pixel, so halve the - // effective pixel cap when we're about to ask vImage for RGBA16. Keeps - // the byte cap constant regardless of source depth, same as the PNG - // halving in src/runtime/image/codec_png.zig. + // 16-bpc doubles bytes/pixel; halve the pixel budget so the byte cap + // stays constant (same as src/runtime/image/codec_png.rs). const uint64_t effective_max_pixels = (bit_depth == 16) ? (max_pixels / 2) : max_pixels; if (static_cast(w) * h > effective_max_pixels) return CG_TOO_MANY_PIXELS; if (!out) { @@ -360,10 +334,9 @@ int32_t bun_coregraphics_decode(const uint8_t* bytes, size_t len, uint64_t max_p return CG_OK; // dimensions-only probe } // TOCTOU guard: the input is a borrowed-but-mutable JS slice and this runs - // on a WorkPool thread, so JS could rewrite it with a *larger* image (or - // one that reports a different bit depth) between the size probe and this - // render. Phase 2 trusts phase 1's dims / bit_depth for the output buffer - // size; refuse to draw past it. + // on a WorkPool thread, so JS could rewrite it between the size probe and + // this render. The caller's `out` is sized from phase 1's dims/bit_depth; + // refuse to draw past it. if (w != *out_w || h != *out_h || bit_depth != *out_bit_depth) return CG_DECODE_FAILED; r.cs = s->CGColorSpaceCreateDeviceRGB(); @@ -371,11 +344,8 @@ int32_t bun_coregraphics_decode(const uint8_t* bytes, size_t len, uint64_t max_p // vImage converts directly to the requested format — including // non-premultiplied alpha, which CGBitmapContext refuses — so the result // is straight RGBA with no premul→unpremul quantisation. kvImageNoAllocate - // makes it write into the caller's bun.default_allocator buffer. - // - // 16-bpc path: bitsPerComponent=16, bitsPerPixel=64, and kBunCGBitmapByte - // Order16Host so the u16 samples land host-endian — matches what libspng - // SPNG_FMT_RGBA16 writes, so the pipeline's Decoded.rgba is uniform. + // makes it write into the caller's bun.default_allocator buffer. 16-bpc + // uses host byte order to match libspng's RGBA16 layout. const uint32_t bpp = bit_depth == 16 ? 64 : 32; const uint32_t bitmap_info = kBunCGImageAlphaLast | (bit_depth == 16 ? kBunCGBitmapByteOrder16Host : 0u); VBuf buf { out, h, w, w * (bpp / 8) }; diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 0a713603ae7a..50fe32cea6a8 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -1697,9 +1697,7 @@ impl PipelineTask { } if matches!(self.kind, Kind::Placeholder) { - // ThumbHash operates on 8-bit RGBA (the hash encoder indexes - // the buffer as u8); `apply_pipeline` is also 8-bpc-only, so - // a 16-bpc source must narrow first. + // ThumbHash operates on 8-bit RGBA. decoded.downconvert_to_8(); self.result = match make_placeholder(&decoded.rgba, decoded.width, decoded.height) { Ok(r) => r, @@ -1736,9 +1734,7 @@ impl PipelineTask { // the profile reinterprets a non-sRGB source (Display-P3, Adobe RGB, // Jpegli XYB) as sRGB and visibly shifts the colours — see #30197. // JPEG/PNG/WebP embed it; HEIC/AVIF via the system backend do not. - // 16-bpc survives only on the PNG-truecolour path — JPEG/WebP/HEIC/ - // AVIF and indexed-PNG encoders are all u8-only. Narrow here so the - // codec arms never see a mismatched buffer. Issue #30462. + // Only PNG truecolour encode honours 16 bpc; narrow for everything else. if enc.format != codecs::Format::Png || enc.palette { decoded.downconvert_to_8(); } @@ -1960,10 +1956,8 @@ impl PipelineTask { /// the profile survives unchanged. fn apply_pipeline(&self, d: &mut codecs::Decoded) -> Result<(), codecs::Error> { let p = &self.pipeline; - // The geometry kernels (rotate/flip/resize) and the modulate pass - // are u8-only. Narrow 16-bpc RGBA to 8 before any op runs. No-op - // when all pipeline slots are empty, which preserves the - // 16-bpc PNG→PNG pass-through from issue #30462. + // The kernels are u8-only; narrow before any op. Skipped when no + // ops are set, preserving the 16-bpc PNG pass-through (#30462). let has_op = p.rotate != 0 || p.flip || p.flop || p.resize.is_some() || p.modulate.is_some(); if has_op { @@ -2085,10 +2079,7 @@ fn apply_orientation( orient: exif::Orientation, ) -> Result<(), codecs::Error> { let t = orient.transform(); - // Same as apply_pipeline — the kernels are u8-only. Reached only - // from the JPEG auto-orient path today, and JPEGs are always - // 8-bpc, but narrow unconditionally so a future non-JPEG EXIF - // path can't skip it. + // Same as apply_pipeline: the kernels are u8-only. if t.flip || t.flop || t.rotate != 0 { d.downconvert_to_8(); } diff --git a/src/runtime/image/backend_coregraphics.rs b/src/runtime/image/backend_coregraphics.rs index 506442b7efbe..f72e42a3aaa0 100644 --- a/src/runtime/image/backend_coregraphics.rs +++ b/src/runtime/image/backend_coregraphics.rs @@ -69,10 +69,7 @@ unsafe extern "C" { max_pixels: u64, out_w: *mut u32, out_h: *mut u32, - // Phase 1 writes 8 or 16 (driven by `kCGImagePropertyDepth`); phase 2 - // reads it to size the VFmt/VBuf for either RGBA8 or RGBA16. Any - // source with depth ≥ 9 (HEIC 10/12, TIFF 16) maps to 16 so the - // extra precision survives through to PNG 16-bpc encode. #30462. + // 8 or 16; phase 1 writes it, phase 2 reads it back for validation. out_bit_depth: *mut u8, out: *mut u8, // nullable ) -> i32; @@ -110,9 +107,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result Result 8 bpc - // (TIFF-16, HEIC 10/12, AVIF 10/12, HDR10 packed), ask WIC to convert - // to 64bppRGBA so the precision survives through to PNG 16-bpc encode. - // Otherwise stay on the 32bppRGBA fast path. Issue #30462. + // Sources carrying > 8 bpc convert to 64bppRGBA so the precision + // survives to PNG 16-bpc encode (#30462); everything else stays 32bppRGBA. let mut src_pf = GUID_WICPixelFormat32bppRGBA; if frame.get_pixel_format(&mut src_pf) < 0 { return Err(DecodeFailed); } let want_16 = is_high_bit_depth_source(&src_pf); - // `max_pixels` is a byte budget in disguise (see codec_png::decode); - // halve the pixel cap when we're about to allocate 8 B/pixel so the - // byte cap stays constant regardless of source depth, same as the PNG - // halving. + // 16-bpc doubles bytes/pixel; halve the pixel budget so the byte cap + // stays constant (same as codec_png::decode). let effective_max_pixels: u64 = if want_16 { max_pixels / 2 } else { max_pixels }; if (w as u64) * (h as u64) > effective_max_pixels { return Err(TooManyPixels); @@ -153,8 +149,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result HRESULT, - // Reports the source's native WIC pixel format GUID. Used by the - // 16-bpc path (issue #30462) to pick between `32bppRGBA` and - // `64bppRGBA` for the convert step so TIFF-16 / HEIC-10 / AVIF-12 - // don't silently downcast to 8-bpc in `WICConvertBitmapSource`. - // Cheap — the header was already parsed by CreateDecoderFromStream. + // Native pixel-format GUID; drives the 32 vs 64 bppRGBA convert target. GetPixelFormat: unsafe extern "system" fn(*mut IWICBitmapSource, *mut GUID) -> HRESULT, GetResolution: *const c_void, CopyPalette: *const c_void, @@ -813,12 +804,8 @@ const GUID_WICPixelFormat32bppRGBA: GUID = GUID { d3: 0x43dd, d4: [0xa7, 0xa8, 0xa2, 0x99, 0x35, 0x26, 0x1a, 0xe9], }; -/// 16-bit-per-channel RGBA, host-endian u16. WIC widens 10/12-bit HDR sources -/// (HEIC/AVIF) and preserves 16-bit sources (TIFF) losslessly when asked for -/// this target. Straight-alpha (not the "PRGBA" premultiplied variant at -/// `…c9, 0x17`, which would quantise through the normal pipeline ops). Layout -/// matches libspng's SPNG_FMT_RGBA16 so a `TIFF 16 → PNG 16` round-trip is -/// bit-identical without a byte swap. Issue #30462. +/// Straight-alpha 16-bpc RGBA, host-endian u16, same layout as libspng's +/// SPNG_FMT_RGBA16. (The 0x17-suffix variant is premultiplied; don't use it.) const GUID_WICPixelFormat64bppRGBA: GUID = GUID { d1: 0x6fddc324, d2: 0x4e03, @@ -837,17 +824,11 @@ const fn wic_pf(suffix: u8) -> GUID { } } -/// Source pixel formats that carry > 8-bit-per-channel precision. Listed -/// explicitly so a future WIC-native format doesn't silently fall back to -/// 8-bpc: adding a new GUID here is the only change needed to preserve its -/// depth. The "Half"/"Float"/"FixedPoint" families are included because -/// WICConvertBitmapSource widens them to u16 RGBA (the float-to-int -/// conversion is `clamp(0..1) * 0xFFFF` in WIC's reference converter). -/// Covers TIFF-16 (48bppRGB), HEIC/AVIF 10/12/16-bit (48bpp or 64bpp -/// flavours), and the 32bppR10G10B10A2 / HDR10 packed-10-bit formats that -/// the Microsoft HEIF Image Extension may emit for HEVC Main10 content. +/// Source pixel formats carrying > 8 bpc; a format not listed decodes at 8. +/// Half/Float/FixedPoint families are included because +/// WICConvertBitmapSource widens them to clamped u16. const HIGH_BPC_SOURCES: [GUID; 15] = [ - // 48bppRGB / 48bppBGR — no-alpha 16-bpc (common TIFF variants). + // 48bppRGB / 48bppBGR (common TIFF-16 variants). wic_pf(0x15), GUID { d1: 0xe605a384, @@ -875,22 +856,14 @@ const HIGH_BPC_SOURCES: [GUID; 15] = [ wic_pf(0x3b), wic_pf(0x12), // 64bppRGBHalf / 64bppRGBAHalf / 64bppRGBAFixedPoint / - // 64bppRGBFixedPoint / 128bppRGBFixedPoint. The 64bpp family is the - // normal HDR TIFF / high-bit-depth output; 128bpp is listed because - // WICConvertBitmapSource narrows it to 64bppRGBA correctly (u32/f32 - // channels → clamped u16) so the carry-through works uniformly even - // for 32-bit-per-channel sources. + // 64bppRGBFixedPoint / 128bppRGBFixedPoint. wic_pf(0x42), wic_pf(0x3a), wic_pf(0x1d), wic_pf(0x40), wic_pf(0x41), - // 32bppR10G10B10A2 / 32bppR10G10B10A2HDR10 — 10-bit samples packed into - // a 32-bit DWORD. The HEIF Image Extension can emit either for Main10 - // HEVC / 10-bit AVIF depending on the source's BT.2020 vs sRGB primaries. - // WICConvertBitmapSource scales 10-bit → 16-bit losslessly (the default - // converter does `value * 0xFFFF / 0x3FF`), so they slot into the same - // 64bppRGBA path as the 48/64 bpp formats above. + // 32bppR10G10B10A2 / 32bppR10G10B10A2HDR10: packed 10-bit, scaled + // losslessly to u16 by WICConvertBitmapSource. GUID { d1: 0x604e1bb5, d2: 0x8a3c, diff --git a/src/runtime/image/codec_png.rs b/src/runtime/image/codec_png.rs index 5e0ac61cc350..d5a7be5e454a 100644 --- a/src/runtime/image/codec_png.rs +++ b/src/runtime/image/codec_png.rs @@ -72,10 +72,8 @@ struct Ihdr { const SPNG_CTX_ENCODER: c_int = 2; const SPNG_FMT_RGBA8: c_int = 1; -/// 16-bit-per-channel RGBA, host-endian. libspng converts the PNG's -/// big-endian samples on decode (and back on encode when the IHDR says 16); -/// the pipeline stores host-endian u16 internally so a 16-bpc decode → -/// 16-bpc encode round-trips without a byte swap on our side. +/// 16-bpc RGBA, host-endian u16; libspng does the big-endian conversion +/// both ways. const SPNG_FMT_RGBA16: c_int = 2; const SPNG_FMT_PNG: c_int = 256; const SPNG_DECODE_TRNS: c_int = 1; // apply tRNS chunk so paletted/grey get real alpha @@ -122,24 +120,16 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result) { let _ = unsafe { spng_set_iccp(ctx, &raw const iccp) }; } -/// `bit_depth` is 8 or 16. 16-bpc input must be host-endian u16 RGBA — -/// `SPNG_FMT_PNG` tells libspng to convert to PNG's big-endian wire format -/// itself (`to_bigendian` flag set when `ihdr.bit_depth == 16`). Everything -/// else — JPEG/WebP/indexed-PNG encode, and the geometry kernels — is -/// u8-only; the caller in Image.rs downconverts first. Issue #30462. +/// `bit_depth` is 8 or 16; 16-bpc input is host-endian u16 RGBA (libspng +/// converts to PNG's big-endian wire format itself via `SPNG_FMT_PNG`). pub(crate) fn encode( rgba: &[u8], w: u32, @@ -229,9 +216,6 @@ pub(crate) fn encode( level: i8, icc_profile: Option<&[u8]>, ) -> Result { - // Programming error if the caller passed an unexpected depth — the - // internal pipeline only produces 8 or 16. A runtime reject here keeps - // a future caller from silently writing a malformed IHDR. if bit_depth != 8 && bit_depth != 16 { return Err(codecs::Error::EncodeFailed); } diff --git a/src/runtime/image/codecs.rs b/src/runtime/image/codecs.rs index 73ebdf6bebf2..5fc90ef865eb 100644 --- a/src/runtime/image/codecs.rs +++ b/src/runtime/image/codecs.rs @@ -1,14 +1,7 @@ //! Thin Rust wrappers over the statically-linked image codecs and the -//! highway resize/rotate kernels. The pipeline is RGBA8 everywhere except -//! the 16-bpc carry-through: libspng (PNG 16-bpc), CoreGraphics (HEIC / -//! AVIF / TIFF with ImageIO depth ≥ 9) and WIC (high-bpc source pixel- -//! format GUIDs — 48bpp RGB, 64bpp RGBA, packed 10-bit HDR10) all emit -//! RGBA16 so high-bit-depth → PNG 16 with no ops survives at full -//! precision (issue #30462). The geometry kernels (resize/rotate/flip/ -//! modulate) and every non-PNG-truecolour encoder are u8-only, so any -//! pipeline op or non-PNG output path downconverts via `downconvert_to_8` -//! before touching that code. JPEG/WebP/BMP/GIF decoders always emit -//! RGBA8, so those paths don't branch on channels. +//! highway resize/rotate kernels. Everything works on RGBA8, except that +//! PNG (libspng), CoreGraphics and WIC decoders emit RGBA16 for sources +//! deeper than 8 bpc; see `Decoded::bit_depth` for how 16-bpc flows through. //! //! Memory ownership: decode returns global-allocator-owned RGBA. Encode //! returns `Encoded{bytes, free}` carrying the codec's own deallocator so the @@ -218,17 +211,10 @@ pub struct Decoded { pub(crate) rgba: Vec, // global allocator (mimalloc) pub(crate) width: u32, pub(crate) height: u32, - /// Bits per channel in `rgba`: 8 (one byte per channel, `width*height*4` - /// bytes) or 16 (two host-endian bytes per channel, `width*height*8` - /// bytes). Set to 16 by libspng's 16-bpc PNG decode path, by the - /// CoreGraphics backend for any HEIC/AVIF/TIFF source whose ImageIO- - /// reported depth is ≥ 9, and by the WIC backend when the source's - /// native pixel-format GUID carries > 8 bpc (48/64 bpp families plus - /// the packed 10-bit HDR10 formats). Every other decoder produces 8. - /// Geometry kernels and non-PNG-truecolour encoders are u8-only, so - /// the pipeline calls `downconvert_to_8` before any op or non-PNG - /// encode — high-bit-depth source → PNG truecolour with no ops is - /// the only path that stays at 16. Issue #30462. + /// Bits per channel in `rgba`: 8 (`w*h*4` bytes) or 16 (host-endian u16, + /// `w*h*8` bytes). 16 comes from >8-bpc PNG / CoreGraphics / WIC sources. + /// Kernels and non-PNG-truecolour encoders are u8-only, so the pipeline + /// narrows via `downconvert_to_8` before any op or non-PNG encode (#30462). pub(crate) bit_depth: u8, /// ICC color profile bytes pulled from the source container (JPEG APP2, /// PNG iCCP, WebP ICCP), global-allocator-owned. `None` when the @@ -257,21 +243,13 @@ impl Default for Decoded { } impl Decoded { - /// Convert `rgba` from 16-bpc host-endian to 8-bpc in place, narrowing - /// each u16 channel to the high byte (equivalent to `>> 8`). A no-op - /// when `bit_depth` is already 8. Called before any transform (the - /// geometry kernels are u8-only) and before non-PNG encode (JPEG/WebP - /// are 8-bpc formats). The buffer is truncated so the tail memory is - /// released on the next realloc; `shrink_to_fit` keeps peak RSS at - /// one frame. + /// Narrow 16-bpc host-endian `rgba` to 8-bpc in place by keeping each + /// u16's high byte (libpng `png_set_strip_16` convention). No-op at 8. pub fn downconvert_to_8(&mut self) { if self.bit_depth != 16 { return; } let samples = (self.width as usize) * (self.height as usize) * 4; - // Narrow by keeping the high byte — same convention as every - // 16→8 PNG down-converter (libpng `png_set_strip_16`, libvips). - // The buffer holds host-endian u16 samples. for i in 0..samples { let v = u16::from_ne_bytes([self.rgba[2 * i], self.rgba[2 * i + 1]]); self.rgba[i] = (v >> 8) as u8; @@ -306,11 +284,9 @@ pub enum Error { bun_core::oom_from_alloc!(Error); /// Sharp's default: 0x3FFF * 0x3FFF ≈ 268 MP. A single RGBA8 frame at this -/// cap is ~1 GiB, which is already past where you'd want to be. 16-bpc -/// decode (issue #30462) doubles bytes-per-pixel, so the guards in -/// `codec_png::decode`, `probe` and the system backends halve the -/// effective pixel budget for 16-bpc sources to keep the byte cap at -/// that same ~1 GiB regardless of source depth. +/// cap is ~1 GiB, which is already past where you'd want to be. The decode +/// guards halve this pixel budget for 16-bpc sources so the byte cap stays +/// constant regardless of source depth. pub(crate) const DEFAULT_MAX_PIXELS: u64 = 0x3FFF * 0x3FFF; /// Hint from the pipeline about the eventual output size. JPEG can do M/8 @@ -413,17 +389,10 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result { } w = u32::from_be_bytes(bytes[16..20].try_into().expect("infallible: size matches")); h = u32::from_be_bytes(bytes[20..24].try_into().expect("infallible: size matches")); - // 16-bpc PNG decode allocates 8 bytes/pixel instead of 4, so - // the `max_pixels` byte budget (documented ~1 GiB at the cap) - // has to halve to stay consistent. Keep probe() in lockstep - // with codec_png::decode()'s guard so `.metadata()` and - // `.bytes()` agree on what's too big. Issue #30462. - // - // Divide the budget rather than multiplying the pixel count — - // `w` and `h` are unvalidated u32 here (the i32 range reject - // runs *after* the match), so `w * h * 2` can overflow u64 - // on a hostile 25-byte IHDR. Two u32 factors always fit in - // u64, and `max_pixels / 2` can't overflow either. + // 16-bpc decodes at 8 bytes/pixel, so halve the pixel budget + // (same guard as codec_png::decode). Divide the budget, don't + // multiply the pixel count: w and h are unvalidated u32s here, + // so `w * h * 2` can overflow u64 on a hostile IHDR. if bytes[24] == 16 && (w as u64) * (h as u64) > max_pixels / 2 { return Err(Error::TooManyPixels); } @@ -607,11 +576,8 @@ impl Encoded { } } -/// `bit_depth` is 8 or 16. Only PNG truecolour encode honours 16; everything -/// else expects 8-bit RGBA. The pipeline in Image.rs downconverts before -/// calling in, so a 16 here on a non-PNG path is a programming error — but -/// the codec arms still assume `rgba.len() == w*h*4` and would miscompute, -/// so keep the precondition in the caller, not a runtime check here. +/// `bit_depth` is honoured only by PNG truecolour encode; the pipeline +/// downconverts before every other path. pub(crate) fn encode( rgba: &[u8], width: u32, @@ -628,8 +594,6 @@ pub(crate) fn encode( // operates on raw RGB numbers without converting colour spaces, so // the palette entries are still in the source space and need the // profile to be interpreted correctly (see PNG spec §11.3.3.3). - // Indexed PNGs are always 8 bpc (palette entries are u8), so the - // caller must have downconverted before choosing the indexed path. Format::Png => { if opts.palette { png::encode_indexed( From 49fbe79888cfe27ef8032eef39aab7e10dd75eee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:42:10 +0000 Subject: [PATCH 3/3] WIC: classify 16-bit grayscale sources as high bit depth --- src/runtime/image/backend_wic.rs | 10 ++++- test/js/bun/image/image.test.ts | 72 +++++++++++++++++++++----------- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/src/runtime/image/backend_wic.rs b/src/runtime/image/backend_wic.rs index 69e3d1b1e0fe..dcccdfc45d26 100644 --- a/src/runtime/image/backend_wic.rs +++ b/src/runtime/image/backend_wic.rs @@ -827,7 +827,15 @@ const fn wic_pf(suffix: u8) -> GUID { /// Source pixel formats carrying > 8 bpc; a format not listed decodes at 8. /// Half/Float/FixedPoint families are included because /// WICConvertBitmapSource widens them to clamped u16. -const HIGH_BPC_SOURCES: [GUID; 15] = [ +const HIGH_BPC_SOURCES: [GUID; 20] = [ + // 16bppGray (the common 16-bit grayscale TIFF: microscopy, scans) + + // 32bppGrayFloat / 16bppGrayFixedPoint / 16bppGrayHalf / + // 32bppGrayFixedPoint. Gray widens to neutral RGB (R=G=B). + wic_pf(0x0b), + wic_pf(0x11), + wic_pf(0x13), + wic_pf(0x3e), + wic_pf(0x3f), // 48bppRGB / 48bppBGR (common TIFF-16 variants). wic_pf(0x15), GUID { diff --git a/test/js/bun/image/image.test.ts b/test/js/bun/image/image.test.ts index ccb2304c354b..7e0ab9ef79cb 100644 --- a/test/js/bun/image/image.test.ts +++ b/test/js/bun/image/image.test.ts @@ -826,32 +826,34 @@ describe("Bun.Image", () => { await expect(new Bun.Image(buf).metadata()).rejects.toThrow(); }); - // Hand-roll a TIFF 6.0 "Baseline RGB" (class R) image at `bits`-per- - // sample (8 or 16). The 12 baseline-required tags are all present - // (ImageWidth, ImageLength, BitsPerSample, Compression=1, Photometric - // Interpretation=2, StripOffsets, SamplesPerPixel, RowsPerStrip, - // StripByteCounts, XResolution, YResolution, ResolutionUnit) — WIC - // in particular is strict about the Resolution tags and rejects the - // file without them. II/little-endian throughout. TIFF routes through - // the system backend on macOS (CoreGraphics) and Windows (WIC); Linux - // returns UnsupportedOnPlatform so the bit_depth plumbing there is - // a no-op. - function makeTiff(w: number, h: number, bits: 8 | 16, pixelOf: (x: number, y: number) => [number, number, number]) { + // Hand-roll a baseline TIFF 6.0 image at `bits`-per-sample (8 or 16). + // All 12 baseline-required tags are present (WIC in particular is + // strict about the Resolution tags and rejects the file without + // them). II/little-endian throughout. TIFF routes through the system + // backend on macOS (CoreGraphics) and Windows (WIC); Linux returns + // UnsupportedOnPlatform so the bit_depth plumbing there is a no-op. + // `samples` selects RGB (3, PhotometricInterpretation=2) or grayscale + // (1, PhotometricInterpretation=1); pixelOf returns that many values. + function makeTiff( + w: number, + h: number, + bits: 8 | 16, + pixelOf: (x: number, y: number) => number[], + samples: 1 | 3 = 3, + ) { const bytesPerSample = bits / 8; - const strip = new Uint8Array(w * h * 3 * bytesPerSample); + const strip = new Uint8Array(w * h * samples * bytesPerSample); const sv = new DataView(strip.buffer); for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { - const [r, g, b] = pixelOf(x, y); - const off = (y * w + x) * 3 * bytesPerSample; - if (bits === 16) { - sv.setUint16(off, r, true); - sv.setUint16(off + 2, g, true); - sv.setUint16(off + 4, b, true); - } else { - strip[off] = r; - strip[off + 1] = g; - strip[off + 2] = b; + const px = pixelOf(x, y); + const off = (y * w + x) * samples * bytesPerSample; + for (let s = 0; s < samples; s++) { + if (bits === 16) { + sv.setUint16(off + 2 * s, px[s], true); + } else { + strip[off + s] = px[s]; + } } } // External-data region follows the IFD. Contents (in emission order): @@ -882,11 +884,12 @@ describe("Bun.Image", () => { // IFD tags must be sorted ascending by tag ID. tag(256, 4, 1, w); // ImageWidth tag(257, 4, 1, h); // ImageLength - tag(258, 3, 3, bitsOff); // BitsPerSample — 3 u16s at external offset + // BitsPerSample: one u16 fits inline; 3 u16s go to an external offset. + tag(258, 3, samples, samples === 1 ? bits : bitsOff); tag(259, 3, 1, 1); // Compression = none - tag(262, 3, 1, 2); // PhotometricInterpretation = RGB + tag(262, 3, 1, samples === 1 ? 1 : 2); // PhotometricInterpretation: BlackIsZero / RGB tag(273, 4, 1, stripOff); // StripOffsets - tag(277, 3, 1, 3); // SamplesPerPixel + tag(277, 3, 1, samples); // SamplesPerPixel tag(278, 4, 1, h); // RowsPerStrip (whole image in one strip) tag(279, 4, 1, strip.length); // StripByteCounts tag(282, 5, 1, xResOff); // XResolution = 72/1 → offset @@ -966,6 +969,25 @@ describe("Bun.Image", () => { }); }); + test("TIFF 16-bpc grayscale → PNG 16-bpc (gray sources widen too)", async () => { + await runTiffTest(async () => { + // WIC reports 16bppGray as the native format; CG reports depth 16. + // The two pixels differ only below the 8-bit threshold (0x8000 vs + // 0x80ff), so any 8-bpc downcast in the chain makes them equal. + // Gray may be colour-managed into RGB (gamma), so assert structure + // (neutral R=G=B, opaque, strictly ordered) rather than exact values. + const src = makeTiff(2, 1, 16, x => [x === 0 ? 0x8000 : 0x80ff], 1); + const out = await new Bun.Image(src).png().bytes(); + expect(pngBitDepth(out)).toBe(16); + const { w, data } = decodePngRaw16(out); + const [r0, g0, b0, a0] = rgba16At(data, w, 0, 0); + const [r1, g1, b1, a1] = rgba16At(data, w, 1, 0); + expect([g0, b0, a0]).toEqual([r0, r0, 0xffff]); + expect([g1, b1, a1]).toEqual([r1, r1, 0xffff]); + expect(r1).toBeGreaterThan(r0); + }); + }); + test("TIFF 16-bpc + resize forces downconvert to 8-bpc (geometry kernels are u8)", async () => { await runTiffTest(async () => { // Same rule as PNG 16 → resize: any pipeline op triggers the u8