Skip to content

Add Bun.Image clone() and stats() - #32123

Open
robobun wants to merge 5 commits into
mainfrom
farm/3d3ecdde/image-clone-stats
Open

Add Bun.Image clone() and stats()#32123
robobun wants to merge 5 commits into
mainfrom
farm/3d3ecdde/image-clone-stats

Conversation

@robobun

@robobun robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Implements the two top-ranked items from #32122: Bun.Image#clone() and Bun.Image#stats().

clone()

const base = new Bun.Image(upload, { maxPixels: 100_000_000 });
const [card, square, max1920, social] = await Promise.all([
  base.clone().resize(1280, 1024).webp({ quality: 80 }).bytes(),
  base.clone().resize(640, 640).webp({ quality: 80 }).bytes(),
  base.clone().resize(1920, 1920, { fit: "inside", withoutEnlargement: true }).webp({ quality: 80 }).bytes(),
  base.clone().resize(1200, 630).jpeg({ quality: 82 }).bytes(),
]);

Sharp-style snapshot: a new independent Image sharing the same input with a copy of the operations recorded so far. Input sharing is zero-copy: Source::Owned becomes Arc<Vec<u8>>, JS-buffer sources point the clone's sourceJS slot at the same ArrayBuffer, Blob sources take a new Strong on the same Blob.

Decode sharing: images in a clone family share a SharedDecode (Arc), whose keyed Weak cache dedupes concurrent full-resolution decodes. The first task decodes while siblings block on the lock, then everyone reuses the pixels; the cache key is wyhash(input bytes) + backend/maxPixels/autoOrient, so a mutated source buffer or a Bun.Image.backend flip re-decodes instead of serving stale pixels. The Weak means an idle family pins no pixel memory.

Sharing engages only where it cannot change the decode a task would do anyway: stats(), placeholder(), transcodes, and every non-JPEG source (their decoders take no hint). JPEG pipelines with a resize keep today's per-pipeline IDCT shrink-on-load decode, untouched. I first tried sharing one full-resolution decode across all variants and benchmarked it slower for the flagship workload (12 MP JPEG into 7 variants: 12.3s vs 7.8s on a debug build), because resizing every variant from the full frame costs more than the saved decodes; Sharp/libvips also shrink-on-load per pipeline rather than share one full decode. With the final gating, the 7-variant JPEG fan-out is identical with clones vs fresh instances, and a stats+placeholder+transcode cluster saves one full decode per extra member (~1.4s CPU on the same debug build, wall time neutral on an idle pool).

Images that never call clone() take exactly the old path: no hashing, no locking, no copy.

stats()

const { dominant, isOpaque, entropy, sharpness, channels } = await new Bun.Image(upload).stats();
dominant; // { r: 40, g: 136, b: 232 }

Same shape as Sharp's stats(): per-channel min/max/sum/squaresSum/mean/stdev (sample stdev, n-1 like vips) with min/max positions, isOpaque, greyscale entropy (Shannon, 256-bin) and sharpness (stdev of a 3x3 Laplacian) estimates, and dominant from the same 4096-bin (16^3) RGB histogram algorithm Sharp uses. Computed in one pass on the worker over the decoded RGBA; stats describe the source image (recorded ops are ignored, like placeholder()). Channels are always the 4 of the RGBA pixel model every decode emits; alpha-less sources report a constant-255 alpha channel.

Bug fix ridden in on purpose

The JPEG decode hint (IDCT downscale) was computed from pipeline.resize regardless of terminal kind, so new Bun.Image(jpeg).resize(10, 10).placeholder() computed the placeholder from a 1/8-scale decode, and stats() would have inherited the same defect (wrong means, wrong min/max positions, width/height reporting the downscaled dims). The hint is now only applied to encode pipelines; test("a recorded resize doesn't shrink the decode stats run on") covers it.

Not in this PR

Scope stays clone() + stats(). Of the remaining #32122 items: fit: "cover"/"contain"/"outside" are in #30616 and position anchors in #31502; AVIF on Linux in #30199/#30204. #31899 covers only part of item 5 (space/channels/hasAlpha); frame/page count, bit depth, EXIF orientation value, and ICC profile presence have no open PR. Items 3 (explicit crop() / position: "attention"), 4 (encoder effort/chromaSubsampling), and 6 (timeout()) also have no coverage and remain open on the issue; each is self-contained and suited to a focused follow-up.

Verification

  • 21 new tests in test/js/bun/image/image.test.ts (clone independence both directions, fan-out, path/BunFile/detached sources, option snapshotting, exact-value stats math recomputed in the test, dominant/entropy/sharpness edge cases, the IDCT-hint regression). All fail on the unfixed build (img.clone is not a function), all pass with the fix.
  • Existing image suites unaffected: 243 tests across the 4 image files pass under the debug (ASAN) build.
  • GC/lifetime stress: 200 iterations of 9 concurrent terminals on clones whose base goes unreachable mid-flight, with Bun.gc(true), clean under ASAN.
  • cargo clippy -p bun_runtime clean; types validated by test/integration/bun-types.

Fixes the clone() and stats() items of #32122.

clone() snapshots an Image into a new independent pipeline sharing the
same input bytes (Arc for owned sources, the same ArrayBuffer for JS
buffer sources) with a copy of the recorded operations, so one upload
can fan out into several variants. Concurrent pipelines in a clone
family whose decode is full resolution (stats, placeholder, transcodes,
any non-JPEG source) dedupe the decode through a keyed weak cache;
hinted JPEG decodes keep the per-pipeline IDCT shrink-on-load path,
which benchmarks faster than sharing a full-resolution decode.

stats() computes Sharp-shaped pixel statistics of the source image on
the worker: per-channel min/max/sum/squaresSum/mean/stdev with min/max
positions, isOpaque, greyscale entropy, laplacian sharpness, and the
dominant sRGB colour from a 4096-bin histogram.

Also gates the JPEG decode hint to encode pipelines: a recorded resize
previously made placeholder() (and would have made stats()) run on an
IDCT-downscaled decode instead of the source.
@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:41 AM PT - Jun 11th, 2026

@robobun, your commit c72c6781660d1ee799a0b963b05ff315f649770d passed in Build #61928! 🎉


🧪   To try this PR locally:

bunx bun-pr 32123

That installs a local version of the PR into your bun-32123 executable, so you can run:

bun-32123 --bun

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds Bun.Image.clone() to snapshot pipelines sharing input/decodes, and Bun.Image.stats() to decode the source image and return per-channel aggregates, opacity, entropy, sharpness, and dominant sRGB. Shared-decode uses an atomic family counter and guarded keyed cache; stats() is a new worker terminal that ignores recorded ops.

Changes

Bun.Image.clone() and Bun.Image.stats() with decode sharing

Layer / File(s) Summary
API contracts and documentation
packages/bun-types/bun.d.ts, docs/runtime/image.mdx
Adds ChannelStats and Stats interfaces, documents clone() and stats() signatures and semantics, and documents that stats() analyzes the source image only and reports channels as [red, green, blue, alpha] (alpha = 255 when absent).
Image class JS bindings
src/runtime/image/Image.classes.ts
Adds JS prototype bindings for clone() and async stats() that call native doClone/doStats.
Shared decode infrastructure
src/runtime/image/Image.rs
Introduces SharedDecode (atomic live-image counter + guarded cache), switches Source::Owned to Arc<Vec<u8>>, updates byte-source paths (data:/Blob/clipboard) to produce Arc-backed bytes, adjusts finalize/size accounting, and wires PipelineTask to carry optional shared decode.
clone() implementation and scheduling
src/runtime/image/Image.rs
Adds Rust do_clone and wrapper logic to snapshot pipeline state into a new Image that shares SharedDecode, increments the family counter, preserves ArrayBuffer-backed source JS where applicable, and snapshots shared eligibility into scheduled tasks; encode body init forces unshared path.
stats() terminal: JS binding, worker routing, and computation
src/runtime/image/Image.classes.ts, src/runtime/image/Image.rs
Adds async stats() binding and Kind::Stats worker path. Worker decoding uses decode_oriented with optional guarded cache reuse; introduces TaskPixels and TaskResult::Stats. Implements compute_stats() to produce per-channel min/max/sum/squaresSum/mean/stdev and extrema positions, luma entropy, Laplacian sharpness, and dominant RGB from a 4096-bin histogram; JS then() maps to Image.Stats and updates width/height.
Test coverage for clone() and stats()
test/js/bun/image/image.test.ts
Adds tests for clone() (operation inheritance, isolation, concurrency, parity vs fresh instances, source coverage, constructor snapshotting, ArrayBuffer detachment) and stats() (opacity, channel aggregates/extrema, entropy, sharpness, dominant color behavior, JPEG full-resolution stats, width/height caching, maxPixels, and clone-shared decode identity).

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly summarizes the main changes: adding two new Bun.Image methods (clone() and stats()).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description provides comprehensive detail on both clone() and stats() implementations, verification approach, scope boundaries, and bug fixes, but does not follow the specified template structure with explicit section headings.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/image/Image.rs (1)

363-372: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't charge shared Arc input bytes to every clone.

Source::Owned is now Arc<Vec<u8>>, but estimated_size() still adds b.len() for every wrapper. A fan-out clone family will therefore report the same encoded input N times to the GC/native-memory accounting even though only one buffer exists, which can artificially increase memory pressure on the exact clone-heavy path this PR adds.

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

In `@src/runtime/image/Image.rs` around lines 363 - 372, estimated_size()
currently charges the full Vec<u8> length for every Image clone because
Source::Owned is now an Arc<Vec<u8>>; change the accounting in the Source::Owned
arm to avoid double-counting shared Arc buffers by using Arc::strong_count to
either (a) only charge the bytes when the Arc is unique (if
Arc::strong_count(&b) == 1 then b.len() else 0) or (b) split the charge across
clones (b.len() / Arc::strong_count(&b) as usize) depending on desired
semantics; update the Source::Owned branch in the estimated_size() function to
use Arc::strong_count(&b) and compute the adjusted byte charge instead of always
adding b.len().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/image/Image.rs`:
- Around line 686-696: When cloning Image you must not copy the runtime cache
last_width/last_height into the new instance; change the Box::new(Image { ...
last_width: Cell::new(self.last_width.get()), last_height:
Cell::new(self.last_height.get()), ... }) initialization to reset those caches
(initialize to an empty/default state rather than copying the parent's values)
and additionally ensure any code paths that mutate the pipeline (places that
call pipeline.set(...) or otherwise change self.pipeline.get())
clear/invalidates the Image cache fields (last_width, last_height) on the
affected instance(s) so cached terminal dimensions are not reused after pipeline
changes.

---

Outside diff comments:
In `@src/runtime/image/Image.rs`:
- Around line 363-372: estimated_size() currently charges the full Vec<u8>
length for every Image clone because Source::Owned is now an Arc<Vec<u8>>;
change the accounting in the Source::Owned arm to avoid double-counting shared
Arc buffers by using Arc::strong_count to either (a) only charge the bytes when
the Arc is unique (if Arc::strong_count(&b) == 1 then b.len() else 0) or (b)
split the charge across clones (b.len() / Arc::strong_count(&b) as usize)
depending on desired semantics; update the Source::Owned branch in the
estimated_size() function to use Arc::strong_count(&b) and compute the adjusted
byte charge instead of always adding b.len().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d19fb116-96e6-4cde-a765-51ee84f04e8b

📥 Commits

Reviewing files that changed from the base of the PR and between f8723b1 and 426b6c0.

📒 Files selected for processing (5)
  • docs/runtime/image.mdx
  • packages/bun-types/bun.d.ts
  • src/runtime/image/Image.classes.ts
  • src/runtime/image/Image.rs
  • test/js/bun/image/image.test.ts

Comment thread src/runtime/image/Image.rs
width/height are documented as -1 until the instance's first awaited
terminal; a clone starts fresh instead of inheriting the parent's last
terminal output. estimated_size now divides the Arc-shared input charge
by the family size so N clones report ~one buffer, not N.
@mintlify

mintlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 11, 2026, 4:18 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/runtime/image/Image.rs (2)

1378-1403: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unwind pending_tasks and reclaim the handler when read_bytes_to_handler() fails.

This path increments pending_tasks, upgrades this_ref, and leaks chain into raw before the ownership transfer. If read_bytes_to_handler() returns Err, none of that is unwound, so the wrapper stays artificially alive and the handler allocation is leaked. Arm a local guard and only disarm it after the read handler has been successfully registered. As per coding guidelines, pair every acquisition with its release at the acquisition site using Drop/RAII guards.

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

In `@src/runtime/image/Image.rs` around lines 1378 - 1403, Wrap the acquisition
sequence (checking/incrementing image.pending_tasks, setting image.this_ref
strong, allocating Box<BlobReadChain> and calling bun_core::heap::into_raw) in a
small RAII guard that, on Drop, will revert those actions if registration fails:
it should decrement image.pending_tasks, clear the strong this_ref if the guard
performed the upgrade, and if a raw pointer was produced reconstruct
Box::from_raw and drop it to free the handler; only disarm the guard after
blob.read_bytes_to_handler(...) returns Ok (i.e. after successful registration).
Apply this around the code that touches image.pending_tasks,
image.this_ref.with_mut, BlobReadChain construction, and
bun_core::heap::into_raw so failure from blob.read_bytes_to_handler does not
leak or leave the wrapper strongly held.

Source: Coding guidelines


1424-1435: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Enforce MAX_INPUT_FILE_BYTES at the actual read boundary, not just before it.

The size cap is bypassable in both byte-materialization paths here: Source::Blob accepts any ReadBytesResult::Ok(bytes) without a length check, so Bun.file(...).image() and other blob-backed sources can still materialize arbitrarily large encoded inputs; Source::Path only checks st_size before read_to_end(), so a concurrently-growing regular file can pass the check and still read past the cap. That defeats the “bound encoded bytes before decode” invariant and reopens OOM/DoS on large inputs. Move the cap into the actual read path: use a bounded file read loop for paths and reject oversize blob reads before storing them, or push both through one capped helper. As per coding guidelines, validate untrusted input before allocation/side effects and fix the whole bug class in the same PR.

Also applies to: 1769-1796

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

In `@src/runtime/image/Image.rs` around lines 1424 - 1435, The code allows
bypassing MAX_INPUT_FILE_BYTES because ReadBytesResult::Ok(bytes) is accepted
unconditionally for Source::Blob and Path reads check st_size before read; fix
by enforcing the cap at the actual read boundary: in the match arm handling
ReadBytesResult::Ok(bytes) (and the other identical arm around lines 1769-1796)
reject or return an error when bytes.len() > MAX_INPUT_FILE_BYTES before calling
image.source.set(Source::Owned(...)); for Source::Path replace the single
read_to_end() with a bounded read loop (or a shared helper like
read_capped(path_or_stream, MAX_INPUT_FILE_BYTES)) that stops and errors if the
cap is exceeded, ensuring all materialisation paths validate untrusted input
size before allocation or storing in image.source.

Source: Coding guidelines

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

Outside diff comments:
In `@src/runtime/image/Image.rs`:
- Around line 1378-1403: Wrap the acquisition sequence (checking/incrementing
image.pending_tasks, setting image.this_ref strong, allocating
Box<BlobReadChain> and calling bun_core::heap::into_raw) in a small RAII guard
that, on Drop, will revert those actions if registration fails: it should
decrement image.pending_tasks, clear the strong this_ref if the guard performed
the upgrade, and if a raw pointer was produced reconstruct Box::from_raw and
drop it to free the handler; only disarm the guard after
blob.read_bytes_to_handler(...) returns Ok (i.e. after successful registration).
Apply this around the code that touches image.pending_tasks,
image.this_ref.with_mut, BlobReadChain construction, and
bun_core::heap::into_raw so failure from blob.read_bytes_to_handler does not
leak or leave the wrapper strongly held.
- Around line 1424-1435: The code allows bypassing MAX_INPUT_FILE_BYTES because
ReadBytesResult::Ok(bytes) is accepted unconditionally for Source::Blob and Path
reads check st_size before read; fix by enforcing the cap at the actual read
boundary: in the match arm handling ReadBytesResult::Ok(bytes) (and the other
identical arm around lines 1769-1796) reject or return an error when bytes.len()
> MAX_INPUT_FILE_BYTES before calling image.source.set(Source::Owned(...)); for
Source::Path replace the single read_to_end() with a bounded read loop (or a
shared helper like read_capped(path_or_stream, MAX_INPUT_FILE_BYTES)) that stops
and errors if the cap is exceeded, ensuring all materialisation paths validate
untrusted input size before allocation or storing in image.source.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 28c2796e-c201-4675-b483-3129f36049d4

📥 Commits

Reviewing files that changed from the base of the PR and between 426b6c0 and ee4ee03.

📒 Files selected for processing (2)
  • src/runtime/image/Image.rs
  • test/js/bun/image/image.test.ts

encode_for_body wraps its PipelineTask in ManuallyDrop (its Drop would
underflow pending_tasks) and releases owned fields manually, but the
SharedDecode Arc added for decode sharing was not released there, so
every Image-as-Response-body encode leaked one ArcInner (caught by
LeakSanitizer on the x64-asan lane). Fold the share flag and the Arc
into one Option field: schedule() passes Some only when the family has
clones, and the ManuallyDrop path carries None so there is nothing to
leak.
@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

On the two outside-diff review findings (no inline threads to resolve, so replying here):

  1. BlobReadChain::start leaking pending_tasks/this_ref/the chain when read_bytes_to_handler fails: real, but pre-existing and not touched by this PR (only the success-path source swap changed here). The fix also has to audit the S3 task pointer ownership inside Blob.rs for the same Err, so it needs its own change. Tracked in Bun.Image: Blob read dispatch failure leaks the read chain and pins the wrapper #32125.

  2. MAX_INPUT_FILE_BYTES on Blob sources and the fstat-then-read race: pre-existing and mostly by design. The constant is documented as a cap for the path-string convenience input; Blob-backed sources read through the Blob's own machinery, exactly like await blob.bytes() everywhere else in Bun, and capping them here would make Bun.file(p).image() stricter than new Bun.Image(await Bun.file(p).bytes()). The authoritative decompression-bomb guard is maxPixels, which runs after the header read and before any pixel allocation on every input path. The fstat cap is a best-effort bound on top of that; a file growing mid-read can exceed it, but an attacker in that position can equally hand the bytes in directly.

The LeakSanitizer failure on the x64-asan lane was real and mine: encode_for_body's ManuallyDrop task never released the new SharedDecode Arc, leaking one ArcInner per Image-used-as-Response-body. Fixed in c9b0a6d by folding the share flag and Arc into one Option field that is None on that path; verified locally with ASAN_OPTIONS=detect_leaks=1 (4 leak reports before, 0 after, full suite green).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/runtime/image/Image.rs (2)

2364-2369: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

stats() adds a full extra image-sized allocation just for sharpness.

Line 2364 materializes an n-byte luma plane so Lines 2440-2465 can make a second pass. At the default maxPixels ceiling, that is another ~256 MiB on top of the already-decoded RGBA buffer, so stats() can OOM inputs that the rest of the pipeline still handles. Sharpness only needs a rolling 3-row window; keeping this O(width) instead of O(pixels) would avoid the new high-water mark.

As per coding guidelines, "Count the copies and allocations your native code makes."

Also applies to: 2440-2465

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

In `@src/runtime/image/Image.rs` around lines 2364 - 2369, The stats() function
currently allocates a full-image luma Vec<u8> (variable luma) for the sharpness
Laplacian pass, which can OOM; change it to a rolling 3-row buffer of size width
(O(width)) reused across the pass instead of allocating n bytes. Specifically,
replace the full-image allocation and uses of luma in the sharpness computation
(the block that reads/constructs luma and the loop in the sharpness Laplacian
pass around lines 2440-2465) with three Vec<u8> row buffers (or a single Vec of
length 3*width) that are try_reserve_exact'ed to width, rotate/reuse them as you
scan rows, and update index arithmetic so the Laplacian reads from the three
current row buffers rather than from luma at pixel-offsets. Ensure error
handling still returns codecs::Error::OutOfMemory on reserve failures and
preserve the same sharpness output semantics.

Source: Coding guidelines


2356-2438: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

stats().dominant is not sRGB with non-sRGB inputs.

Line 2356 bins the raw decoded RGB values, but this file explicitly preserves the source ICC profile instead of color-converting decoded RGBA first (see Lines 1936-1945). For Display-P3 / AdobeRGB assets, stats().dominant therefore comes back in source space, while the new public contract in packages/bun-types/bun.d.ts describes an sRGB color and even shows feeding it into CSS rgb(...). Either convert to sRGB before histogramming or narrow the new runtime/docs contract before merge.

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

Outside diff comments:
In `@src/runtime/image/Image.rs`:
- Around line 2364-2369: The stats() function currently allocates a full-image
luma Vec<u8> (variable luma) for the sharpness Laplacian pass, which can OOM;
change it to a rolling 3-row buffer of size width (O(width)) reused across the
pass instead of allocating n bytes. Specifically, replace the full-image
allocation and uses of luma in the sharpness computation (the block that
reads/constructs luma and the loop in the sharpness Laplacian pass around lines
2440-2465) with three Vec<u8> row buffers (or a single Vec of length 3*width)
that are try_reserve_exact'ed to width, rotate/reuse them as you scan rows, and
update index arithmetic so the Laplacian reads from the three current row
buffers rather than from luma at pixel-offsets. Ensure error handling still
returns codecs::Error::OutOfMemory on reserve failures and preserve the same
sharpness output semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 929f7c24-f6e0-4b6e-be75-5cb6bdeab02e

📥 Commits

Reviewing files that changed from the base of the PR and between ee4ee03 and c9b0a6d.

📒 Files selected for processing (1)
  • src/runtime/image/Image.rs

The sharpness Laplacian previously materialized a full image-sized luma
plane, adding 25% to stats()'s peak memory on top of the decoded RGBA.
Luma rows are now recomputed into a 3-row rolling window.

Also drop the sRGB claim from the dominant-colour docs: statistics are
computed over the decoded pixels, which stay in the source colour space
because the pipeline preserves the ICC profile instead of converting
(Sharp converts via vips' colour engine first; Bun does not ship one).
sRGB sources, the overwhelming default, are unaffected.
@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Both outside-diff findings from the latest review round are addressed in 4e8f999: the sharpness pass now uses a 3-row rolling luma window instead of a full image-sized plane (O(width) extra memory), and the dominant-colour contract no longer claims sRGB — stats are documented as computed in the image's own colour space, since the pipeline preserves the ICC profile instead of converting (Bun ships no colour engine; Sharp converts through vips first). Sharpness output is bit-identical, all 243 image tests green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/bun-types/bun.d.ts (1)

8519-8524: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align Image.stats() docs with Image.Stats.dominant color-space contract.

Line 8523 still says “dominant sRGB colour”, but Image.Stats.dominant now documents source-color-space output. This creates conflicting API semantics in the same .d.ts.

Suggested doc fix
-     * `isOpaque`, greyscale `entropy` and `sharpness` estimates, and the
-     * `dominant` sRGB colour — e.g. for placeholder backgrounds.
+     * `isOpaque`, greyscale `entropy` and `sharpness` estimates, and the
+     * `dominant` colour in the image's own colour space — e.g. for
+     * placeholder backgrounds.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bun-types/bun.d.ts` around lines 8519 - 8524, Update the JSDoc for
Image.stats() to match the Image.Stats.dominant contract: remove or replace the
phrase "dominant sRGB colour" and state that the `dominant` property is reported
in the image's source color space (same behavior as Image.Stats.dominant).
Locate the Image.stats() documentation block and change its wording so it no
longer asserts sRGB output and instead explicitly documents that `dominant` uses
the source color space, keeping the rest of the stats description unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/bun-types/bun.d.ts`:
- Around line 8519-8524: Update the JSDoc for Image.stats() to match the
Image.Stats.dominant contract: remove or replace the phrase "dominant sRGB
colour" and state that the `dominant` property is reported in the image's source
color space (same behavior as Image.Stats.dominant). Locate the Image.stats()
documentation block and change its wording so it no longer asserts sRGB output
and instead explicitly documents that `dominant` uses the source color space,
keeping the rest of the stats description unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a3ed6fcd-9123-476a-9517-65c92e8b5394

📥 Commits

Reviewing files that changed from the base of the PR and between c9b0a6d and 4e8f999.

📒 Files selected for processing (3)
  • docs/runtime/image.mdx
  • packages/bun-types/bun.d.ts
  • src/runtime/image/Image.rs

@hjaber

hjaber commented Jun 11, 2026

Copy link
Copy Markdown

The triage reply on #32122 contains two inaccuracies worth correcting while this PR is open:

  1. It points item 5 (richer metadata()) at Add space, channels, and hasAlpha to Bun.Image metadata() #31899, but Add space, channels, and hasAlpha to Bun.Image metadata() #31899 only adds space, channels, and hasAlpha. The fields requested in item 5 are frame/page count, bit depth, EXIF orientation value, and ICC profile presence. None of those are in Add space, channels, and hasAlpha to Bun.Image metadata() #31899 or any other open PR.
  2. Items 3 (explicit crop(left, top, width, height)), 4 (encoder effort and chromaSubsampling options), and 6 (timeout()) have no coverage anywhere. Bun.Image: implement fit: "outside" / "cover" / "contain" #30616/Bun.Image: add fit: "cover" and position to .resize() for anchor-based cropping #31502 cover fit modes and anchors, not these.

Of those, crop(), timeout(), and the encode options are each small, fully specified in #32122 with Sharp-parity semantics, and orthogonal to this PR's scope. If extending this PR isn't appropriate, follow-up PRs against #32122 would close the remaining gaps.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

You're right on both points, thanks for the careful read. Corrected the triage comment on #32122 and this PR's scope section: #31899 covers only space/channels/hasAlpha from item 5 (frame/page count, bit depth, EXIF orientation value, and ICC presence have no open PR), and items 3 (crop() / position: "attention"), 4 (encoder effort/chromaSubsampling), and 6 (timeout()) have no coverage anywhere.

Keeping this PR scoped to clone() + stats(): it's through several review rounds and CI is close to green, and each of the remaining items is self-contained enough to land as its own focused PR against #32122. The issue now reflects accurately which items remain open.

@hjaber

hjaber commented Jun 11, 2026

Copy link
Copy Markdown

Agreed on keeping this PR scoped. Since you've sized items 3, 4, and 6 as self-contained, opening those focused PRs against #32122 would close out the issue: crop(left, top, width, height), encoder effort/chromaSubsampling, and timeout({ seconds }) are each fully specified in the issue with Sharp-parity semantics. The item-5 metadata fields (frame/page count, bit depth, EXIF orientation value, ICC presence) would fit naturally as a follow-up to #31899's approach.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Sizing notes for whoever picks these up (with one correction to my earlier "self-contained" claim):

  • crop(left, top, width, height): genuinely small. One pipeline slot between flip/flop and resize (crop in upright post-orient space, matching Sharp's common .extract().resize() order), a row-copy kernel, bounds validation against the decoded dimensions. The one design decision is Sharp's call-order sensitivity (extract can run before or after resize depending on call order); Bun.Image's fixed-order single-slot model would pin it to pre-resize, documented.
  • Encoder effort/chromaSubsampling: medium. WebP maps cleanly (WebPConfig.method, use_sharp_yuv); JPEG subsampling maps to tj3's sampling option; PNG effort and AVIF/HEIC via the system backends don't map 1:1, so each format needs an accept-or-reject decision rather than silently ignoring the option.
  • timeout({ seconds }): I oversized my "self-contained" claim here. The static codecs decode one-shot (tj3 has no progress callback), so a watchdog can only check between pipeline stages and cannot interrupt the exact threat the issue names, a pathologically slow decode. Sharp gets this from vips' eval callbacks. Honest parity needs incremental-decode plumbing per codec (spng progressive, WebPIDecoder) or a kill mechanism for worker tasks, which is a bigger change than the API suggests.
  • Item-5 metadata fields: agreed they extend Add space, channels, and hasAlpha to Bun.Image metadata() #31899's probe approach, and should build on that PR after it lands to avoid conflicting with it.

On opening them now: there are currently 8 open Bun.Image PRs from this account awaiting review (this one plus #31898, #31899, #30616, #30204, #30463, #30237, #30202). The bottleneck is review bandwidth, not PR production, so I'd rather not stack three more onto that queue unprompted. If a maintainer wants any of these next, say the word and it gets a focused PR.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants