Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 55 additions & 14 deletions src/jsc/bindings/image_coregraphics_shim.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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.
Comment thread
robobun marked this conversation as resolved.
CFRef* kCGImagePropertyDepth;
const void* kCFTypeDictionaryKeyCallBacks;
const void* kCFTypeDictionaryValueCallBacks;
};
Expand All @@ -117,7 +126,9 @@ constexpr struct {
SYM(CFDataGetBytePtr),
SYM(CFStringCreateWithCString),
SYM(CFNumberCreate),
SYM(CFNumberGetValue),
SYM(CFDictionaryCreate),
SYM(CFDictionaryGetValue),
SYM(CGColorSpaceCreateDeviceRGB),
SYM(CGColorSpaceRelease),
SYM(CGImageCreate),
Expand All @@ -128,6 +139,7 @@ constexpr struct {
SYM(CGDataProviderRelease),
SYM(CGImageSourceCreateWithData),
SYM(CGImageSourceCreateImageAtIndex),
SYM(CGImageSourceCopyPropertiesAtIndex),
SYM(CGImageDestinationCreateWithData),
SYM(CGImageDestinationAddImage),
SYM(CGImageDestinationFinalize),
Expand All @@ -138,6 +150,7 @@ constexpr struct {
SYM(vImageVerticalReflect_ARGB8888),
SYM(kCFAllocatorNull),
SYM(kCGImageDestinationLossyCompressionQuality),
SYM(kCGImagePropertyDepth),
SYM(kCFTypeDictionaryKeyCallBacks),
SYM(kCFTypeDictionaryValueCallBacks),
};
Expand Down Expand Up @@ -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).
Comment thread
robobun marked this conversation as resolved.
constexpr uint32_t kBunCGBitmapByteOrder16Host = 1u << 12; // 0x1000
// vImage_Flags — values copied verbatim from <Accelerate/vImage_Types.h>;
// 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.
Expand Down Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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;
Expand Down Expand Up @@ -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<uint64_t>(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<CFRef>(const_cast<void*>(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).
Comment thread
robobun marked this conversation as resolved.
const uint64_t effective_max_pixels = (bit_depth == 16) ? (max_pixels / 2) : max_pixels;
if (static_cast<uint64_t>(w) * h > effective_max_pixels) return CG_TOO_MANY_PIXELS;
if (!out) {
*out_w = static_cast<uint32_t>(w);
*out_h = static_cast<uint32_t>(h);
*out_bit_depth = static_cast<uint8_t>(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.
Comment thread
robobun marked this conversation as resolved.
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.
Comment thread
robobun marked this conversation as resolved.
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
Expand Down Expand Up @@ -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; }
Expand Down
27 changes: 25 additions & 2 deletions src/runtime/image/Image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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).
Comment thread
robobun marked this conversation as resolved.
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
Expand Down Expand Up @@ -2017,7 +2036,7 @@ fn make_placeholder(rgba: &[u8], sw: u32, sh: u32) -> Result<TaskResult, codecs:
// `rendered.rgba` is owned; drops at scope exit.
// Placeholder is a synthetic ThumbHash render, not the source image —
// no ICC profile attaches to it.
let out = codecs::png::encode(&rendered.rgba, rendered.w, rendered.h, -1, None)?;
let out = codecs::png::encode(&rendered.rgba, rendered.w, rendered.h, 8, -1, None)?;
let _ = owned; // explicit lifetime hint; drops here.
Ok(TaskResult::Encoded {
out,
Expand Down Expand Up @@ -2060,6 +2079,10 @@ fn apply_orientation(
orient: exif::Orientation,
) -> 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;
Expand Down
18 changes: 15 additions & 3 deletions src/runtime/image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Name>` method that parses args, writes the slot, returns
`callframe.this()`.
3. Add it to `proto:` in `Image.classes.ts`.
Expand All @@ -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
Expand Down
15 changes: 11 additions & 4 deletions src/runtime/image/backend_coregraphics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -103,8 +105,9 @@ fn map_err(rc: i32) -> BackendError {
pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, BackendError> {
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.
Comment thread
robobun marked this conversation as resolved.
// SAFETY: bytes is a valid slice; out=null signals "probe only" to the shim.
match unsafe {
bun_coregraphics_decode(
Expand All @@ -113,25 +116,28 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, B
max_pixels,
&raw mut w,
&raw mut h,
&raw mut bit_depth,
core::ptr::null_mut(),
)
} {
CG_OK => {}
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(),
bytes.len(),
max_pixels,
&raw mut w,
&raw mut h,
&raw mut bit_depth,
out.as_mut_ptr(),
)
} {
Expand All @@ -142,6 +148,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, B
rgba: out,
width: w,
height: h,
bit_depth,
icc_profile: None,
})
}
Expand Down
Loading
Loading