diff --git a/src/jsc/bindings/image_coregraphics_shim.cpp b/src/jsc/bindings/image_coregraphics_shim.cpp index da2242c4ade5..92ba8a31896d 100644 --- a/src/jsc/bindings/image_coregraphics_shim.cpp +++ b/src/jsc/bindings/image_coregraphics_shim.cpp @@ -70,7 +70,11 @@ struct Syms { const uint8_t* (*CFDataGetBytePtr)(CFRef); CFRef (*CFStringCreateWithCString)(CFRef, const char*, uint32_t); CFRef (*CFNumberCreate)(CFRef, int, const void*); + // 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*); + // Returns a borrowed reference; do not release. + const void* (*CFDictionaryGetValue)(CFRef, const void*); // CoreGraphics CFRef (*CGColorSpaceCreateDeviceRGB)(); void (*CGColorSpaceRelease)(CFRef); @@ -83,6 +87,8 @@ struct Syms { // ImageIO CFRef (*CGImageSourceCreateWithData)(CFRef, CFRef); CFRef (*CGImageSourceCreateImageAtIndex)(CFRef, size_t, CFRef); + // 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); bool (*CGImageDestinationFinalize)(CFRef); @@ -96,6 +102,9 @@ struct Syms { // address and dereference at use-site). CFRef* kCFAllocatorNull; CFRef* kCGImageDestinationLossyCompressionQuality; + // 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; }; @@ -117,7 +126,9 @@ constexpr struct { SYM(CFDataGetBytePtr), SYM(CFStringCreateWithCString), SYM(CFNumberCreate), + SYM(CFNumberGetValue), SYM(CFDictionaryCreate), + SYM(CFDictionaryGetValue), SYM(CGColorSpaceCreateDeviceRGB), SYM(CGColorSpaceRelease), SYM(CGImageCreate), @@ -128,6 +139,7 @@ constexpr struct { SYM(CGDataProviderRelease), SYM(CGImageSourceCreateWithData), SYM(CGImageSourceCreateImageAtIndex), + SYM(CGImageSourceCopyPropertiesAtIndex), SYM(CGImageDestinationCreateWithData), SYM(CGImageDestinationAddImage), SYM(CGImageDestinationFinalize), @@ -138,6 +150,7 @@ constexpr struct { SYM(vImageVerticalReflect_ARGB8888), SYM(kCFAllocatorNull), SYM(kCGImageDestinationLossyCompressionQuality), + SYM(kCGImagePropertyDepth), SYM(kCFTypeDictionaryKeyCallBacks), SYM(kCFTypeDictionaryValueCallBacks), }; @@ -178,6 +191,11 @@ 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 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) // and silently turned every CG decode into 0xAA garbage in debug builds. @@ -256,12 +274,13 @@ 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 -// the buffer like every other decode path. +// Decode `bytes[0..len)` into a caller-allocated RGBA buffer. Two-phase: +// 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) + 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,26 +308,48 @@ 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; + 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)) { + // vImage widens 10/12-bit samples to u16, so > 8 bpc maps to 16. + if (raw >= 9) bit_depth = 16; + } + } + s->CFRelease(props); + } + } + // 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) { *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 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(); if (!r.cs) return CG_UNAVAILABLE; // 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. - VBuf buf { out, h, w, w * 4 }; - VFmt fmt { 8, 32, r.cs, kBunCGImageAlphaLast, 0, nullptr, 0 }; + // 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) }; + 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 +596,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..50fe32cea6a8 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -1697,6 +1697,8 @@ impl PipelineTask { } if matches!(self.kind, Kind::Placeholder) { + // ThumbHash operates on 8-bit RGBA. + 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 +1734,22 @@ 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. + // Only PNG truecolour encode honours 16 bpc; narrow for everything else. + 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 +1956,13 @@ impl PipelineTask { /// the profile survives unchanged. fn apply_pipeline(&self, d: &mut codecs::Decoded) -> Result<(), codecs::Error> { let p = &self.pipeline; + // 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 { + 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 +2036,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. + 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..f72e42a3aaa0 100644 --- a/src/runtime/image/backend_coregraphics.rs +++ b/src/runtime/image/backend_coregraphics.rs @@ -69,6 +69,8 @@ unsafe extern "C" { max_pixels: u64, out_w: *mut u32, out_h: *mut u32, + // 8 or 16; phase 1 writes it, phase 2 reads it back for validation. + out_bit_depth: *mut u8, out: *mut u8, // nullable ) -> i32; @@ -103,8 +105,9 @@ 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. // SAFETY: bytes is a valid slice; out=null signals "probe only" to the shim. match unsafe { bun_coregraphics_decode( @@ -113,18 +116,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 +137,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result Result Result max_pixels { + + // 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); + // 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); } - // 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)?; @@ -136,7 +150,8 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result u32::MAX as u64 { @@ -154,11 +169,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 +723,8 @@ struct IWICBitmapSource { struct IWICBitmapSourceVTable { unk: IUnknownVTable, GetSize: unsafe extern "system" fn(*mut IWICBitmapSource, *mut u32, *mut u32) -> HRESULT, - GetPixelFormat: *const c_void, + // 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, CopyPixels: unsafe extern "system" fn( @@ -783,6 +804,91 @@ const GUID_WICPixelFormat32bppRGBA: GUID = GUID { d3: 0x43dd, d4: [0xa7, 0xa8, 0xa2, 0x99, 0x35, 0x26, 0x1a, 0xe9], }; +/// 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, + 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 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; 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 { + 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. + wic_pf(0x42), + wic_pf(0x3a), + wic_pf(0x1d), + wic_pf(0x40), + wic_pf(0x41), + // 32bppR10G10B10A2 / 32bppR10G10B10A2HDR10: packed 10-bit, scaled + // losslessly to u16 by WICConvertBitmapSource. + 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 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, h: u32, + bit_depth: u8, level: i8, icc_profile: Option<&[u8]>, ) -> Result { + 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 +239,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 (`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 /// source didn't carry one or the decode path doesn't extract it — @@ -226,6 +230,36 @@ 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 { + /// 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; + 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 +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. +/// 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 @@ -347,12 +383,19 @@ 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 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); + } } Format::Jpeg => { // turbojpeg's header decode is already cheap (no scan data read). @@ -533,10 +576,13 @@ impl Encoded { } } +/// `bit_depth` is honoured only by PNG truecolour encode; the pipeline +/// downconverts before every other path. 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 @@ -560,7 +606,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 +773,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,413 @@ 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 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 * samples * bytesPerSample); + const sv = new DataView(strip.buffer); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + 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): + // - 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 + // 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, samples === 1 ? 1 : 2); // PhotometricInterpretation: BlackIsZero / RGB + tag(273, 4, 1, stripOff); // StripOffsets + 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 + 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 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 + // 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