diff --git a/docs/runtime/image.mdx b/docs/runtime/image.mdx index a6ef5803d597..2e8fe4c6b5ac 100644 --- a/docs/runtime/image.mdx +++ b/docs/runtime/image.mdx @@ -50,6 +50,36 @@ const { width, height, format } = await new Bun.Image(input).metadata(); // => { width: 1920, height: 1080, format: "jpeg" } ``` +## Statistics + +`.stats()` decodes the image and computes pixel-derived statistics — per-channel `min`/`max`/`sum`/`squaresSum`/`mean`/`stdev` with min/max positions, `isOpaque`, greyscale `entropy` and `sharpness` estimates, and the dominant color from a 4,096-bin histogram (the same shape and dominant-color algorithm as Sharp's `stats()`): + +```ts +const { dominant, isOpaque, channels } = await new Bun.Image(input).stats(); +const background = `rgb(${dominant.r} ${dominant.g} ${dominant.b})`; // paint behind the LQIP +channels[0].mean; // average red value +``` + +Statistics describe the _source_ image — chained operations like `resize` are ignored, the same as `.placeholder()`. Channels are always reported in `[red, green, blue, alpha]` order; sources without an alpha channel report a constant-255 alpha. All statistics (including `dominant`) are computed over the decoded pixels in the image's own color space — sRGB for the overwhelming majority of images; sources tagged with a non-sRGB ICC profile (Display P3, Adobe RGB) report source-space values, since the pipeline preserves the profile instead of converting. + +## Clone + +`.clone()` snapshots an instance into a new, independent pipeline sharing the same input without copying it — Sharp's `clone()`. Use it to fan one upload out into several delivery variants: + +```ts +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(), +]); +``` + +Operations recorded before `.clone()` are copied into the clone; operations recorded after affect only the instance they're called on. + +Concurrent pipelines in a clone family that decode at full resolution — `.stats()`, `.placeholder()`, format transcodes, and any non-JPEG source — share a single decode of the input. JPEG pipelines with a `.resize()` keep per-pipeline shrink-on-load decoding (the M/8 IDCT fast path, same strategy as Sharp), which beats sharing a full-resolution decode. + ## Resize ```ts diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index b654ae4ff5fd..bca5075e42f5 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -8277,6 +8277,68 @@ declare module "bun" { height: number; format: Format; } + + /** + * Statistics for one channel of the RGBA pixel model every decode + * produces, in `[red, green, blue, alpha]` order. Sources without an + * alpha channel (JPEG, opaque PNG) report a constant-255 alpha channel. + */ + interface ChannelStats { + /** Smallest value in the channel (0–255). */ + min: number; + /** Largest value in the channel (0–255). */ + max: number; + /** Sum of all values. */ + sum: number; + /** Sum of all values squared. */ + squaresSum: number; + /** Arithmetic mean of the values. */ + mean: number; + /** Sample standard deviation (n−1 denominator, like Sharp/libvips). */ + stdev: number; + /** x coordinate of the first pixel (scan order) holding `min`. */ + minX: number; + /** y coordinate of the first pixel (scan order) holding `min`. */ + minY: number; + /** x coordinate of the first pixel (scan order) holding `max`. */ + maxX: number; + /** y coordinate of the first pixel (scan order) holding `max`. */ + maxY: number; + } + + /** + * Pixel-derived statistics of the source image, resolved by + * {@link Image.stats}. The shape matches Sharp's `stats()`. + */ + interface Stats { + /** Per-channel statistics, always `[red, green, blue, alpha]`. */ + channels: [ChannelStats, ChannelStats, ChannelStats, ChannelStats]; + /** `true` unless any pixel's alpha is below 255. */ + isOpaque: boolean; + /** + * Shannon entropy of a 256-bin greyscale histogram — a busyness + * estimate. `0` for a flat single-colour image, up to 8 for noise. + */ + entropy: number; + /** + * Standard deviation of a 3×3 Laplacian over the greyscale image — a + * focus estimate. `0` for a flat image; blurry images score lower than + * sharp ones. + */ + sharpness: number; + /** + * Most dominant colour, from a 4,096-bin (16×16×16) histogram of the + * decoded pixels — the same algorithm as Sharp. Values are bin + * centres, so each component has the form `16·k + 8`. + * + * Like every other statistic here, this is computed in the image's + * own colour space: sRGB for untagged and sRGB sources (the + * overwhelming default). Sources carrying a non-sRGB ICC profile + * (Display P3, Adobe RGB) report source-space components, consistent + * with `channels`. + */ + dominant: { r: number; g: number; b: number }; + } } /** @@ -8343,6 +8405,28 @@ declare module "bun" { constructor(input: string | ArrayBuffer | NodeJS.TypedArray | Blob, options?: Image.ConstructorOptions); + /** + * Snapshot this instance into a new, independent `Image` sharing the + * same input (no copy), with a copy of the operations recorded so far. + * Use it to fan one source out into several pipelines. Concurrent + * pipelines in a clone family that decode at full resolution — `stats`, + * `placeholder`, transcodes, and any non-JPEG source — share a single + * decode; JPEG pipelines with a `resize` keep per-pipeline + * shrink-on-load decoding (like Sharp), which is faster than sharing a + * full-resolution decode. + * + * @example + * ```ts + * const base = new Bun.Image(upload); + * const [card, square, og] = await Promise.all([ + * base.clone().resize(1280, 1024).webp({ quality: 80 }).bytes(), + * base.clone().resize(640, 640).webp({ quality: 80 }).bytes(), + * base.clone().resize(1200, 630).jpeg({ quality: 82 }).bytes(), + * ]); + * ``` + */ + clone(): Image; + /** Set target dimensions. Omit `height` to keep the source aspect ratio. */ resize(width: number, height?: number, options?: Image.ResizeOptions): this; /** Rotate by a multiple of 90°. */ @@ -8431,6 +8515,21 @@ declare module "bun" { toBase64(): Promise; /** Decode just enough to read width/height/format. */ metadata(): Promise; + /** + * Decode and compute pixel-derived statistics of the *source* image + * (recorded operations are ignored, like {@link placeholder}): + * per-channel min/max/sum/squaresSum/mean/stdev and min/max positions, + * `isOpaque`, greyscale `entropy` and `sharpness` estimates, and the + * `dominant` colour in the image's own colour space (see + * {@link Image.Stats.dominant}) — e.g. for placeholder backgrounds. + * + * @example + * ```ts + * const { dominant, isOpaque } = await new Bun.Image(upload).stats(); + * const css = `rgb(${dominant.r} ${dominant.g} ${dominant.b})`; + * ``` + */ + stats(): Promise; /** Populated after the first awaited terminal; `-1` before. */ readonly width: number; diff --git a/src/runtime/image/Image.classes.ts b/src/runtime/image/Image.classes.ts index 1338eab1c6f9..6db38ba07f24 100644 --- a/src/runtime/image/Image.classes.ts +++ b/src/runtime/image/Image.classes.ts @@ -35,6 +35,10 @@ export default [ clipboardChangeCount: { fn: "clipboardChangeCount", length: 0 }, }, proto: { + // Snapshot: a new Image sharing this one's input with a copy of the + // ops recorded so far (Sharp's clone()). Clone-family members dedupe + // concurrent full-resolution decodes — see SharedDecode in Image.rs. + clone: { fn: "doClone", length: 0 }, // Chainable mutators — record an op and return `this`. resize: { fn: "doResize", length: 2 }, rotate: { fn: "doRotate", length: 1 }, @@ -64,6 +68,9 @@ export default [ // / blurDataURL. placeholder: { fn: "doPlaceholder", length: 0, async: true }, metadata: { fn: "doMetadata", length: 0, async: true }, + // Sharp-shaped pixel statistics of the SOURCE image (ops ignored): + // per-channel stats, isOpaque, entropy, sharpness, dominant colour. + stats: { fn: "doStats", length: 0, async: true }, // Read-only after a pipeline has run; -1 before. width: { getter: "getWidth" }, diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 65116761a2d3..b09b0ae3a882 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -10,6 +10,8 @@ use core::cell::Cell; use core::mem; +use core::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Weak}; use crate::generated_classes::PropertyName; use crate::webcore::Blob; @@ -27,6 +29,7 @@ use bun_jsc::{ JsRef, JsResult, StringJsc as _, Strong, SysErrorJsc as _, }; use bun_sys as sys; +use bun_threading::Guarded; use super::codecs; use super::exif; @@ -78,6 +81,9 @@ pub struct Image { /// collect the wrapper without polling `hasPendingActivity` every cycle. this_ref: JsCell, pending_tasks: Cell, + /// Decode-sharing state for this image's clone family (`clone()` passes + /// the `Arc` along; independently constructed images never share one). + shared: Arc, } impl Default for Image { @@ -91,6 +97,42 @@ impl Default for Image { last_height: Cell::new(-1), this_ref: JsCell::new(JsRef::empty()), pending_tasks: Cell::new(0), + shared: Arc::new(SharedDecode::default()), + } + } +} + +/// State shared by every `Image` in one clone family so concurrent +/// full-resolution pipelines (`stats()`, `placeholder()`, transcodes, any +/// non-JPEG source) decode the input once instead of once per member. See +/// `decode_oriented` for when sharing engages. +struct SharedDecode { + /// Live `Image` wrappers holding this `Arc`. Decode-sharing only engages + /// when > 1 — a lone image keeps today's per-terminal hinted decode + /// (JPEG IDCT downscale) byte-for-byte. Touched from the JS thread + /// (`clone()`) and GC sweep (`finalize()`), hence atomic. + images: AtomicU32, + /// Full-resolution post-auto-orient pixels of the family's input. `Weak` + /// so the cache pins nothing once no in-flight task holds the `Arc` — + /// overlapping terminals (the `Promise.all` fan-out) share one decode, + /// an idle family costs no memory, and a fully sequential family simply + /// re-decodes. `key` fingerprints everything that shapes the decode so a + /// mutated source buffer, a changed file, or a `Bun.Image.backend` flip + /// can never be served stale pixels. + cache: Guarded, +} + +#[derive(Default)] +struct DecodeCacheSlot { + key: u64, + decoded: Weak, +} + +impl Default for SharedDecode { + fn default() -> Self { + Self { + images: AtomicU32::new(1), + cache: Guarded::init(DecodeCacheSlot::default()), } } } @@ -108,8 +150,9 @@ pub enum Source { /// more than the dupe it replaces. JsBuffer, /// Owned — Blob inputs (the Blob's store may be sliced/freed independently) - /// and decoded data: URLs. - Owned(Vec), + /// and decoded data: URLs. `Arc` so `clone()` shares the bytes instead of + /// duping them per clone; the buffer is immutable once stored. + Owned(Arc>), /// Owned, NUL-terminated. Read on the worker thread. Path(ZBox), /// `Bun.file()`, `Bun.s3()`, an fd-backed Blob — anything whose bytes @@ -312,6 +355,7 @@ impl Image { // false positive on that contract. #[allow(clippy::boxed_local)] pub fn finalize(self: Box) { + self.shared.images.fetch_sub(1, Ordering::Relaxed); self.this_ref.with_mut(|r| r.finalize()); // `source` is dropped by Box drop. } @@ -323,7 +367,12 @@ impl Image { mem::size_of::() + match self.source.get() { Source::JsBuffer | Source::Blob(_) => 0, - Source::Owned(b) => b.len(), + // Clones share the buffer; splitting the charge keeps a + // family's total at ~one buffer instead of N of them. Only + // Image wrappers hold this Arc, so the count is the family + // size (atomic load; an estimate racing a concurrent clone + // is fine). + Source::Owned(b) => b.len() / Arc::strong_count(b), Source::Path(p) => p.len(), } } @@ -392,7 +441,7 @@ fn source_from_js( ))); } out.truncate(r.written); - return Ok(Source::Owned(out)); + return Ok(Source::Owned(Arc::new(out))); } return Ok(Source::Path(ZBox::from_bytes(s))); } @@ -419,7 +468,7 @@ fn source_from_js( // independently). let view = blob.shared_view(); if !view.is_empty() { - return Ok(Source::Owned(view.to_vec())); + return Ok(Source::Owned(Arc::new(view.to_vec()))); } // Anything with a backing store but no in-memory view yet // (`Bun.file()`, `Bun.s3()`, fd, …) — keep the JS object and read it @@ -619,6 +668,51 @@ impl Image { pub fn do_format_avif(&self, g: &JSGlobalObject, cf: &CallFrame) -> JsResult { self.set_format(g, cf, codecs::Format::Avif) } + + /// `.clone()` — Sharp-style snapshot: a NEW `Image` sharing this one's + /// input and a copy of the ops recorded so far, so one upload can fan out + /// into several independent pipelines. Clone-family members also share + /// `SharedDecode`, so overlapping full-resolution decodes happen once. + #[bun_jsc::host_fn(method)] + pub fn do_clone(&self, global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let source = match self.source.get() { + // The new wrapper gets its own `sourceJS` cached slot pointing at + // the same ArrayBuffer — set after `to_js()` below. + Source::JsBuffer => Source::JsBuffer, + Source::Owned(b) => Source::Owned(Arc::clone(b)), + // Path strings are tiny; an owned copy keeps the sources + // independent (no shared ZBox plumbing for a few bytes). + Source::Path(p) => Source::Path(ZBox::from_bytes(p.as_bytes())), + // Same JS Blob; each instance reads it independently. Once either + // side's read completes, that side swaps to `.Owned` as usual. + Source::Blob(strong) => Source::Blob(Strong::create(strong.get(), global)), + }; + self.shared.images.fetch_add(1, Ordering::Relaxed); + let img = Box::new(Image { + source: JsCell::new(source), + pipeline: Cell::new(self.pipeline.get()), + max_pixels: self.max_pixels, + auto_orient: self.auto_orient, + // `.width`/`.height` are documented as -1 until THIS instance's + // first awaited terminal; the parent's last terminal output says + // nothing about the clone's pipeline. + last_width: Cell::new(-1), + last_height: Cell::new(-1), + this_ref: JsCell::new(JsRef::empty()), + pending_tasks: Cell::new(0), + shared: Arc::clone(&self.shared), + }); + let cloned = img.to_js(global); + if matches!(self.source.get(), Source::JsBuffer) { + // `None` ⇒ the slot was never populated (shouldn't happen for a + // live `.JsBuffer`); leave the clone's slot empty and its + // terminals reject as detached, same as the parent would. + if let Some(src) = js::source_js_get_cached(callframe.this()) { + js::source_js_set_cached(cloned, global, src); + } + } + Ok(cloned) + } } /// Stable `.code` so callers can branch without parsing the message — and so @@ -847,7 +941,7 @@ impl Image { Err(_) => return Ok(JSValue::NULL), }; let img = Box::new(Image { - source: JsCell::new(Source::Owned(bytes)), + source: JsCell::new(Source::Owned(Arc::new(bytes))), ..Default::default() }); return Ok(img.to_js(global)); @@ -1023,6 +1117,21 @@ impl Image { self.schedule(global, cf.this(), Kind::Placeholder, Deliver::DataUrl) } + /// `.stats()` — pixel-derived statistics of the SOURCE image (recorded + /// ops are ignored, like `.placeholder()`): per-channel min/max/sum/ + /// squaresSum/mean/stdev + min/max positions, `isOpaque`, greyscale + /// `entropy`, laplacian `sharpness`, and the `dominant` colour from a + /// 4096-bin 3D histogram — the same shape (and dominant algorithm) as + /// Sharp's `stats()`. All values are computed over the decoded pixels, + /// which stay in the source colour space (the pipeline carries the ICC + /// profile instead of converting — see the encode path), so a non-sRGB + /// source reports source-space numbers where Sharp would convert to + /// sRGB first. + #[bun_jsc::host_fn(method)] + pub fn do_stats(&self, global: &JSGlobalObject, cf: &CallFrame) -> JsResult { + self.schedule(global, cf.this(), Kind::Stats, Deliver::Uint8Array) + } + /// Terminal: encode and write to `path` on the work pool (no round-trip of /// then `Bun.write(dest, encoded)` — same path as `await Bun.write(...)`, so /// `dest` may be a path string, `Bun.file()`, `Bun.s3()`, or an fd. Resolves @@ -1111,6 +1220,8 @@ impl Image { deliver, max_pixels: self.max_pixels, auto_orient: self.auto_orient, + shared: (self.shared.images.load(Ordering::Relaxed) > 1) + .then(|| Arc::clone(&self.shared)), result: TaskResult::Err(codecs::Error::DecodeFailed), }); // First in-flight task ⇒ hold a Strong ref to the wrapper so GC can't @@ -1197,6 +1308,11 @@ impl Image { deliver: Deliver::Uint8Array, max_pixels: self.max_pixels, auto_orient: self.auto_orient, + // The unshared path, always: the synchronous JS-thread encode + // must never block on a worker's in-flight decode — and the + // `ManuallyDrop` above skips field drops, so this arm must not + // own anything (an `Arc` here would leak its refcount). + shared: None, result: TaskResult::Err(codecs::Error::DecodeFailed), }); task.run(); @@ -1220,7 +1336,7 @@ impl Image { ))), // Preserve errno/path/syscall instead of flattening to DecodeFailed. TaskResult::IoErr(e) => Err(global.throw_value(e.to_js(global))), - TaskResult::Meta { .. } => unreachable!(), + TaskResult::Meta { .. } | TaskResult::Stats(..) => unreachable!(), } } } @@ -1321,7 +1437,7 @@ impl<'a> BlobReadChain<'a> { // — drop the redundant read instead and re-enter `schedule()` // on the already-swapped source. if matches!(image.source.get(), Source::Blob(_)) { - image.source.set(Source::Owned(bytes)); + image.source.set(Source::Owned(Arc::new(bytes))); } else { drop(bytes); } @@ -1396,6 +1512,14 @@ pub struct PipelineTask<'a> { deliver: Deliver, max_pixels: u64, auto_orient: bool, + /// `Some` ⇒ the clone family had more than one live image at schedule + /// time and the worker dedupes full-resolution decodes through this + /// cache (see `decode_oriented`); the `Arc` snapshot is taken on the JS + /// thread so the worker never touches `Image` fields. `None` ⇒ the + /// unshared pre-`clone()` path. This is an OWNING field: any constructor + /// whose task skips `Drop` (`encode_for_body`'s `ManuallyDrop`) must + /// pass `None` or release it manually. + shared: Option>, result: TaskResult, } @@ -1468,6 +1592,10 @@ pub enum Kind { /// hash itself never crosses the JS boundary unless we add an /// `as: "hash"` option later. Placeholder, + /// `.stats()` — decode → single-pass channel statistics + histograms on + /// the worker. Like `.placeholder()`, stats are OF the source: recorded + /// pipeline ops are not applied. + Stats, } pub enum TaskResult { @@ -1482,11 +1610,133 @@ pub enum TaskResult { h: u32, format: codecs::Format, }, + /// Boxed — the payload is ~400 bytes and only exists for `.stats()`. + Stats(Box), Err(codecs::Error), IoErr(sys::Error), } +/// Decoded pixels for one task: exclusively owned (the non-shared path — +/// identical to pre-`clone()` behaviour) or a handle on the clone family's +/// shared decode. +enum TaskPixels { + Owned(codecs::Decoded), + Shared(Arc), +} + +impl core::ops::Deref for TaskPixels { + type Target = codecs::Decoded; + fn deref(&self) -> &codecs::Decoded { + match self { + TaskPixels::Owned(d) => d, + TaskPixels::Shared(a) => a, + } + } +} + +impl TaskPixels { + /// Exclusive `Decoded` for the mutating encode path, plus — for the + /// shared case — the `Arc` the caller keeps alive until the task is done + /// (maximises the window in which sibling tasks can still upgrade the + /// cache's `Weak`). The copy is one memcpy of the RGBA frame; decode + /// sharing saves a full decode per sibling, which dwarfs it. + fn into_parts(self) -> (codecs::Decoded, Option>) { + match self { + TaskPixels::Owned(d) => (d, None), + TaskPixels::Shared(a) => ( + codecs::Decoded { + rgba: a.rgba.clone(), + width: a.width, + height: a.height, + icc_profile: a.icc_profile.clone(), + }, + Some(a), + ), + } + } +} + impl<'a> PipelineTask<'a> { + /// Decode + EXIF auto-orient. When this image had live clones at + /// schedule time (`self.shared` is `Some`) and the decode would be + /// full-resolution anyway, it goes through the family's cache: the first + /// task to arrive decodes while siblings block on the lock, then + /// everyone shares the same pixels. Otherwise this is exactly the + /// pre-`clone()` path — hinted decode, no hashing, no locking. + fn decode_oriented( + &self, + input: &[u8], + src_format: codecs::Format, + hint: codecs::DecodeHint, + ) -> Result { + // Share only when sharing doesn't change the decode this task would + // do anyway. The JPEG decoder downscales during IDCT when a resize + // target is known (`hint`), which both skips work and shrinks every + // later stage — sharing one full-resolution decode across + // different-size variants benchmarks SLOWER than per-task hinted + // decodes (resizing from the full frame costs more than the saved + // decode; Sharp/libvips likewise shrink-on-load per pipeline). So a + // hinted JPEG task keeps the unshared path, while everything whose + // decode is full-resolution regardless — stats, placeholder, + // transcodes, and every non-JPEG format (their decoders ignore the + // hint) — dedupes through the family cache. + let share = self.shared.as_ref().filter(|_| { + src_format != codecs::Format::Jpeg || (hint.target_w == 0 && hint.target_h == 0) + }); + if let Some(shared) = share { + // Everything that shapes the decoded pixels goes into the key: + // the input bytes (a mutated source ArrayBuffer or a rewritten + // file re-decodes instead of being served stale pixels) and the + // knobs below. `max_pixels`/`auto_orient` are family-uniform + // (read-only after construction, copied by `clone()`) but folded + // in anyway; `backend` can change between tasks via + // `Bun.Image.backend`. + let seed = self + .max_pixels + .wrapping_mul(31) + .wrapping_add(u64::from(codecs::BACKEND.load(Ordering::Relaxed)) << 1) + .wrapping_add(u64::from(self.auto_orient)); + let key = bun_wyhash::hash_with_seed(seed, input); + // The lock is held across the decode on purpose: every task on + // this path performs the identical full-resolution decode, so + // blocking a sibling until the fill finishes is strictly cheaper + // than letting it duplicate the work. + let mut slot = shared.cache.lock(); + if slot.key == key { + if let Some(arc) = slot.decoded.upgrade() { + return Ok(TaskPixels::Shared(arc)); + } + } + let mut d = codecs::decode(input, self.max_pixels, codecs::DecodeHint::default())?; + self.orient(&mut d, input, src_format)?; + let arc = Arc::new(d); + slot.key = key; + slot.decoded = Arc::downgrade(&arc); + Ok(TaskPixels::Shared(arc)) + } else { + let mut d = codecs::decode(input, self.max_pixels, hint)?; + self.orient(&mut d, input, src_format)?; + Ok(TaskPixels::Owned(d)) + } + } + + /// EXIF auto-orient: applied BEFORE any user op so resize targets and + /// metadata report the visually-upright dimensions, the way Sharp does. + fn orient( + &self, + d: &mut codecs::Decoded, + input: &[u8], + src_format: codecs::Format, + ) -> Result<(), codecs::Error> { + if self.auto_orient && src_format == codecs::Format::Jpeg { + let orient = exif::read_jpeg(input); + if orient != exif::Orientation::Normal { + apply_orientation(d, orient)?; + } + } + Ok(()) + } + /// Runs on a `WorkPool` thread. No JSC access. pub fn run(&mut self) { // `self.input` was prepared on the JS thread by `pin_for_task`: either a @@ -1593,7 +1843,15 @@ impl<'a> PipelineTask<'a> { // can be over-shrunk and then upscaled, throwing away detail. // (flip/flop are pure mirrors that never change w/h, so the hint // stays valid through them.) - let hint: codecs::DecodeHint = if let Some(r) = self.pipeline.resize { + // + // Encode kinds only: the hint describes the ENCODE pipeline's resize + // target. `.stats()`/`.placeholder()` are OF the source and skip the + // pipeline, so a recorded resize must not downscale their decode. + let resize_hint = match self.kind { + Kind::Encode(_) => self.pipeline.resize, + Kind::Metadata | Kind::Placeholder | Kind::Stats => None, + }; + let hint: codecs::DecodeHint = if let Some(r) = resize_hint { let mut tw = r.w; // r.h==0 means "preserve aspect" — constrain on width only. let mut th = if r.h != 0 { r.h } else { r.w }; @@ -1613,47 +1871,50 @@ impl<'a> PipelineTask<'a> { codecs::DecodeHint::default() }; - let mut decoded = match codecs::decode(input, self.max_pixels, hint) { - Ok(d) => d, + let src_format = codecs::Format::sniff(input).unwrap_or(codecs::Format::Png); + + let pixels = match self.decode_oriented(input, src_format, hint) { + Ok(p) => p, Err(e) => { self.result = TaskResult::Err(e); return; } }; - // `defer decoded.deinit()` — `codecs::Decoded` Drop frees rgba/icc. - - let src_format = codecs::Format::sniff(input).unwrap_or(codecs::Format::Png); - - // EXIF auto-orient: applied BEFORE any user op so resize targets and - // metadata report the visually-upright dimensions, the way Sharp does. - if self.auto_orient && src_format == codecs::Format::Jpeg { - let orient = exif::read_jpeg(input); - if orient != exif::Orientation::Normal { - if let Err(e) = apply_orientation(&mut decoded, orient) { - self.result = TaskResult::Err(e); - return; - } - } - } + // `defer decoded.deinit()` — `codecs::Decoded` Drop frees rgba/icc + // (for the shared case, when the last task's `Arc` drops). if matches!(self.kind, Kind::Metadata) { // Reached only for HEIC/AVIF (probe fell through). self.result = TaskResult::Meta { - w: decoded.width, - h: decoded.height, + w: pixels.width, + h: pixels.height, format: src_format, }; return; } if matches!(self.kind, Kind::Placeholder) { - self.result = match make_placeholder(&decoded.rgba, decoded.width, decoded.height) { + self.result = match make_placeholder(&pixels.rgba, pixels.width, pixels.height) { Ok(r) => r, Err(e) => TaskResult::Err(e), }; return; } + if matches!(self.kind, Kind::Stats) { + self.result = match compute_stats(&pixels) { + Ok(st) => TaskResult::Stats(st), + Err(e) => TaskResult::Err(e), + }; + return; + } + + // Encode path mutates, so take exclusive pixels. `_family_keepalive` + // holds the family's shared decode until this task finishes, so a + // sibling clone that starts while we resize/encode still hits the + // cache instead of finding a dead `Weak`. + let (mut decoded, _family_keepalive) = pixels.into_parts(); + if let Err(e) = self.apply_pipeline(&mut decoded) { self.result = TaskResult::Err(e); return; @@ -1725,6 +1986,12 @@ impl<'a> PipelineTask<'a> { image.last_width.set(i32::try_from(*w).expect("int cast")); image.last_height.set(i32::try_from(*h).expect("int cast")); } + TaskResult::Stats(st) => { + image.last_width.set(i32::try_from(st.w).expect("int cast")); + image + .last_height + .set(i32::try_from(st.h).expect("int cast")); + } _ => {} } // `Drop` forbids moving out of `self.result`; swap in a @@ -1885,6 +2152,45 @@ impl<'a> PipelineTask<'a> { obj.put(global, b"format", fmt_js); promise.resolve(global, obj)?; } + TaskResult::Stats(st) => { + let obj = JSValue::create_empty_object(global, 5); + let channels = match JSValue::create_empty_array(global, st.channels.len()) { + Ok(v) => v, + Err(_) => return promise.reject(global, Err(jsc::JsError::Thrown)), + }; + for (i, ch) in st.channels.iter().enumerate() { + let c = JSValue::create_empty_object(global, 10); + c.put(global, b"min", JSValue::js_number(f64::from(ch.min))); + c.put(global, b"max", JSValue::js_number(f64::from(ch.max))); + // u64 → f64 may round above 2^53; the JS number is the + // best representation available either way. + c.put(global, b"sum", JSValue::js_number(ch.sum as f64)); + c.put( + global, + b"squaresSum", + JSValue::js_number(ch.squares_sum as f64), + ); + c.put(global, b"mean", JSValue::js_number(ch.mean)); + c.put(global, b"stdev", JSValue::js_number(ch.stdev)); + c.put(global, b"minX", JSValue::js_number(f64::from(ch.min_x))); + c.put(global, b"minY", JSValue::js_number(f64::from(ch.min_y))); + c.put(global, b"maxX", JSValue::js_number(f64::from(ch.max_x))); + c.put(global, b"maxY", JSValue::js_number(f64::from(ch.max_y))); + if channels.put_index(global, i as u32, c).is_err() { + return promise.reject(global, Err(jsc::JsError::Thrown)); + } + } + obj.put(global, b"channels", channels); + obj.put(global, b"isOpaque", JSValue::from(st.is_opaque)); + obj.put(global, b"entropy", JSValue::js_number(st.entropy)); + obj.put(global, b"sharpness", JSValue::js_number(st.sharpness)); + let dom = JSValue::create_empty_object(global, 3); + dom.put(global, b"r", JSValue::js_number(f64::from(st.dominant[0]))); + dom.put(global, b"g", JSValue::js_number(f64::from(st.dominant[1]))); + dom.put(global, b"b", JSValue::js_number(f64::from(st.dominant[2]))); + obj.put(global, b"dominant", dom); + promise.resolve(global, obj)?; + } TaskResult::Err(e) => promise.reject(global, Ok(reject_error(global, e)))?, TaskResult::IoErr(e) => promise.reject(global, Ok(e.to_js(global)))?, } @@ -1983,6 +2289,206 @@ fn make_placeholder(rgba: &[u8], sw: u32, sh: u32) -> Result Result, codecs::Error> { + let (w, h) = (d.width, d.height); + let n = u64::from(w) * u64::from(h); + let mut stats = Box::new(ImageStats { + w, + h, + channels: [ChannelStats::default(); 4], + is_opaque: true, + entropy: 0.0, + sharpness: 0.0, + dominant: [8, 8, 8], + }); + if n == 0 { + return Ok(stats); + } + + #[derive(Clone, Copy)] + struct Acc { + min: u8, + max: u8, + min_i: u64, + max_i: u64, + sum: u64, + // Cannot overflow for any decodable image: 255² per pixel caps a u64 + // at ~2.8e14 pixels ≈ 1.1 PB of RGBA, far past any allocatable frame. + sq: u64, + } + let mut acc = [Acc { + min: 255, + max: 0, + min_i: 0, + max_i: 0, + sum: 0, + sq: 0, + }; 4]; + // `dominant`: Sharp's algorithm — 4096-bin (16³) RGB histogram, alpha + // dropped, answer is the fullest bin's centre. u64 counts so a raised + // `maxPixels` can't overflow a bin. + let mut hist3d = vec![0u64; 4096]; + // `entropy`: Shannon entropy of a 256-bin greyscale histogram. Integer + // BT.601 luma (77/150/29, Σ=256) — documented as an estimate; vips + // converts through LAB so Sharp's absolute numbers differ slightly. + let mut luma_hist = [0u64; 256]; + + for (i, px) in d.rgba.chunks_exact(4).enumerate() { + for (&v, a) in px.iter().zip(acc.iter_mut()) { + if v < a.min { + a.min = v; + a.min_i = i as u64; + } + if v > a.max { + a.max = v; + a.max_i = i as u64; + } + a.sum += u64::from(v); + a.sq += u64::from(v) * u64::from(v); + } + let (r, g, b) = (u32::from(px[0]), u32::from(px[1]), u32::from(px[2])); + hist3d[(((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4)) as usize] += 1; + // (77·255 + 150·255 + 29·255 + 128) >> 8 == 255 — can't escape the + // table. + let y = ((r * 77 + g * 150 + b * 29 + 128) >> 8) as u8; + luma_hist[usize::from(y)] += 1; + if px[3] != 255 { + stats.is_opaque = false; + } + } + + let nf = n as f64; + for (out, a) in stats.channels.iter_mut().zip(acc.iter()) { + // `.max(0.0)` guards f64 rounding driving the radicand a hair + // negative on constant channels. + let stdev = if n > 1 { + ((a.sq as f64 - (a.sum as f64) * (a.sum as f64) / nf) / (nf - 1.0)) + .max(0.0) + .sqrt() + } else { + 0.0 + }; + *out = ChannelStats { + min: a.min, + max: a.max, + sum: a.sum, + squares_sum: a.sq, + mean: a.sum as f64 / nf, + stdev, + min_x: (a.min_i % u64::from(w)) as u32, + min_y: (a.min_i / u64::from(w)) as u32, + max_x: (a.max_i % u64::from(w)) as u32, + max_y: (a.max_i / u64::from(w)) as u32, + }; + } + + for &count in &luma_hist { + if count > 0 { + let p = count as f64 / nf; + stats.entropy -= p * p.log2(); + } + } + + let mut best = 0usize; + for (i, &count) in hist3d.iter().enumerate().skip(1) { + if count > hist3d[best] { + best = i; + } + } + stats.dominant = [ + ((best >> 8) as u8) * 16 + 8, + (((best >> 4) & 0xF) as u8) * 16 + 8, + ((best & 0xF) as u8) * 16 + 8, + ]; + + // `sharpness`: standard deviation of the (3×3, scale-9) laplacian over + // the greyscale image — Sharp's estimate. Interior pixels only (vips + // extends the border instead; the difference is negligible past icon + // sizes); 0 when there is no interior. Luma rows are recomputed into a + // 3-row rolling window so stats() stays O(width) extra memory — a full + // image-sized luma plane would add 25% to the peak on top of the + // decoded RGBA. + if w >= 3 && h >= 3 { + let stride = w as usize; + let mut rows: [Vec; 3] = [const { Vec::new() }; 3]; + for row in &mut rows { + if row.try_reserve_exact(stride).is_err() { + return Err(codecs::Error::OutOfMemory); + } + row.resize(stride, 0); + } + let luma_row = |y: usize, out: &mut [u8]| { + let base = y * stride * 4; + for (px, out_y) in d.rgba[base..base + stride * 4] + .chunks_exact(4) + .zip(out.iter_mut()) + { + let (r, g, b) = (u32::from(px[0]), u32::from(px[1]), u32::from(px[2])); + *out_y = ((r * 77 + g * 150 + b * 29 + 128) >> 8) as u8; + } + }; + luma_row(0, &mut rows[0]); + luma_row(1, &mut rows[1]); + let (mut sum, mut sq) = (0.0f64, 0.0f64); + for y in 1..(h as usize - 1) { + luma_row(y + 1, &mut rows[(y + 1) % 3]); + let prev = &rows[(y - 1) % 3]; + let cur = &rows[y % 3]; + let next = &rows[(y + 1) % 3]; + for x in 1..(stride - 1) { + let lap = f64::from( + i32::from(prev[x]) + + i32::from(cur[x - 1]) + + i32::from(cur[x + 1]) + + i32::from(next[x]) + - 4 * i32::from(cur[x]), + ) / 9.0; + sum += lap; + sq += lap * lap; + } + } + let cnt = f64::from(w - 2) * f64::from(h - 2); + if cnt > 1.0 { + stats.sharpness = ((sq - sum * sum / cnt) / (cnt - 1.0)).max(0.0).sqrt(); + } + } + + Ok(stats) +} + /// Map a resize spec to concrete output dims given the current dims. fn resolve_resize(r: Resize, sw: u32, sh: u32) -> (u32, u32) { let mut w = r.w; diff --git a/test/js/bun/image/image.test.ts b/test/js/bun/image/image.test.ts index c5a7ffbf4bb3..0331ce55e1f0 100644 --- a/test/js/bun/image/image.test.ts +++ b/test/js/bun/image/image.test.ts @@ -1527,3 +1527,239 @@ describe("Bun.Image.backend", () => { } }); }); + +// ─── clone() ──────────────────────────────────────────────────────────────── + +describe("Bun.Image clone()", () => { + test("returns a new Image inheriting the ops recorded so far", async () => { + const base = new Bun.Image(cornersPng).rotate(90); + const clone = base.clone(); + expect(clone).toBeInstanceOf(Bun.Image); + expect(clone).not.toBe(base); + // Inherited rotate(90) of the 4×3 fixture → 3×4. + const out = decodePngRaw(await clone.png().bytes()); + expect([out.w, out.h]).toEqual([3, 4]); + }); + + test("ops recorded after clone() are independent in both directions", async () => { + const base = new Bun.Image(gradientPng); + const clone = base.clone().resize(4, 4); + base.resize(8, 8); + const [fromBase, fromClone] = await Promise.all([base.png().bytes(), clone.png().bytes()]); + expect([decodePngRaw(fromBase).w, decodePngRaw(fromBase).h]).toEqual([8, 8]); + expect([decodePngRaw(fromClone).w, decodePngRaw(fromClone).h]).toEqual([4, 4]); + }); + + test("output format set on a clone doesn't leak to the parent", async () => { + const base = new Bun.Image(cornersPng); + const webpClone = base.clone().webp(); + const [baseOut, cloneOut] = await Promise.all([base.bytes(), webpClone.bytes()]); + expect(baseOut.subarray(1, 4)).toEqual(new Uint8Array([0x50, 0x4e, 0x47])); // "PNG" (source format reused) + expect(cloneOut.subarray(0, 4)).toEqual(new Uint8Array([0x52, 0x49, 0x46, 0x46])); // "RIFF" + }); + + test("concurrent fan-out over one buffer: each clone gets its own output", async () => { + const base = new Bun.Image(gradientPng); + const sizes = [2, 3, 4, 6, 8, 12]; + const outs = await Promise.all(sizes.map(s => base.clone().resize(s, s).png().bytes())); + for (let i = 0; i < sizes.length; i++) { + const d = decodePngRaw(outs[i]); + expect([d.w, d.h]).toEqual([sizes[i], sizes[i]]); + } + }); + + test("clone output is byte-identical to a fresh instance with the same ops", async () => { + const base = new Bun.Image(gradientPng); + // Concurrent clones take the shared-decode path; fresh instances don't. + const [a, b] = await Promise.all([base.clone().resize(8, 8).png().bytes(), base.clone().flop().png().bytes()]); + expect(a).toEqual(await new Bun.Image(gradientPng).resize(8, 8).png().bytes()); + expect(b).toEqual(await new Bun.Image(gradientPng).flop().png().bytes()); + }); + + test("clone of a path-backed image", async () => { + using dir = tempDir("image-clone-path", { "src.png": Buffer.from(gradientPng) }); + const base = new Bun.Image(join(String(dir), "src.png")); + const [a, b] = await Promise.all([ + base.clone().resize(4, 4).png().bytes(), + base.clone().resize(2, 2).png().bytes(), + ]); + expect([decodePngRaw(a).w, decodePngRaw(b).w]).toEqual([4, 2]); + }); + + test("clone of a Bun.file-backed image", async () => { + using dir = tempDir("image-clone-file", { "src.png": Buffer.from(gradientPng) }); + const base = Bun.file(join(String(dir), "src.png")).image(); + const clone = base.clone(); + const [a, b] = await Promise.all([base.resize(8, 8).png().bytes(), clone.resize(4, 4).png().bytes()]); + expect([decodePngRaw(a).w, decodePngRaw(b).w]).toEqual([8, 4]); + }); + + test("clone snapshots constructor options; dimension getters start fresh", async () => { + const big = new Bun.Image(gradientPng, { maxPixels: 4 }); // 16×16 > 4 + await expect(big.clone().png().bytes()).rejects.toMatchObject({ code: "ERR_IMAGE_TOO_MANY_PIXELS" }); + + const base = new Bun.Image(cornersPng); + await base.metadata(); + const clone = base.clone(); + // width/height are -1 until the clone's own first awaited terminal. + expect([clone.width, clone.height]).toEqual([-1, -1]); + expect(await clone.metadata()).toEqual({ width: 4, height: 3, format: "png" }); + expect([clone.width, clone.height]).toEqual([4, 3]); + expect([base.width, base.height]).toEqual([4, 3]); + }); + + test("clone of a detached ArrayBuffer source rejects like the parent would", async () => { + const ab = new Uint8Array(gradientPng).buffer.slice(0); + const base = new Bun.Image(ab); + const clone = base.clone(); + structuredClone(ab, { transfer: [ab] }); // detaches `ab` + await expect(clone.png().bytes()).rejects.toMatchObject({ code: "ERR_INVALID_STATE" }); + }); + + test("concurrent mixed terminals across a clone family", async () => { + const base = new Bun.Image(gradientPng); + const [bytes, stats, meta, lqip, rotated] = await Promise.all([ + base.clone().resize(8, 8).png().bytes(), + base.clone().stats(), + base.clone().metadata(), + base.clone().placeholder(), + base.clone().rotate(90).png().bytes(), + ]); + expect(decodePngRaw(bytes).w).toBe(8); + expect(stats.channels).toHaveLength(4); + expect(meta).toEqual({ width: 16, height: 16, format: "png" }); + expect(lqip).toStartWith("data:image/png;base64,"); + expect([decodePngRaw(rotated).w, decodePngRaw(rotated).h]).toEqual([16, 16]); + }); +}); + +// ─── stats() ──────────────────────────────────────────────────────────────── + +describe("Bun.Image stats()", () => { + test("solid colour: exact channel stats, dominant, entropy 0, sharpness 0", async () => { + // Channel values of the form 16k+8 sit exactly on a histogram bin centre, + // so `dominant` reports them verbatim. + const png = makePng(8, 8, () => [40, 136, 232, 255]); + const s = await new Bun.Image(png).stats(); + expect(s.isOpaque).toBe(true); + expect(s.entropy).toBe(0); + expect(s.sharpness).toBe(0); + expect(s.dominant).toEqual({ r: 40, g: 136, b: 232 }); + expect(s.channels).toHaveLength(4); + expect(s.channels[0]).toEqual({ + min: 40, + max: 40, + sum: 40 * 64, + squaresSum: 40 * 40 * 64, + mean: 40, + stdev: 0, + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + }); + // Alpha channel of an opaque image is constant 255. + expect(s.channels[3].min).toBe(255); + expect(s.channels[3].max).toBe(255); + expect(s.channels[3].stdev).toBe(0); + }); + + test("sum/squaresSum/mean/stdev match their definitions on a gradient", async () => { + // Recompute the expected values from the same pixel function the + // gradientPng fixture uses (16×16, v = round((x+y)/30·255) in r=g=b). + const vals: number[] = []; + for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) vals.push(Math.round(((x + y) / 30) * 255)); + const n = vals.length; + const sum = vals.reduce((a, v) => a + v, 0); + const sq = vals.reduce((a, v) => a + v * v, 0); + const stdev = Math.sqrt((sq - (sum * sum) / n) / (n - 1)); // sample stdev, like Sharp/vips + + const s = await new Bun.Image(gradientPng).stats(); + for (const c of [0, 1, 2]) { + expect(s.channels[c].sum).toBe(sum); + expect(s.channels[c].squaresSum).toBe(sq); + expect(s.channels[c].mean).toBeCloseTo(sum / n, 10); + expect(s.channels[c].stdev).toBeCloseTo(stdev, 10); + } + // min 0 only at (0,0); max 255 only at (15,15). + expect(s.channels[0].min).toBe(0); + expect(s.channels[0].max).toBe(255); + expect([s.channels[0].minX, s.channels[0].minY]).toEqual([0, 0]); + expect([s.channels[0].maxX, s.channels[0].maxY]).toEqual([15, 15]); + }); + + test("isOpaque flips on a single translucent pixel, with its position", async () => { + const png = makePng(4, 4, (x, y) => [10, 20, 30, x === 2 && y === 1 ? 128 : 255]); + const s = await new Bun.Image(png).stats(); + expect(s.isOpaque).toBe(false); + expect(s.channels[3].min).toBe(128); + expect([s.channels[3].minX, s.channels[3].minY]).toEqual([2, 1]); + }); + + test("dominant picks the majority colour; 50/50 two-tone entropy is exactly 1", async () => { + // 60% white / 40% black → the white bin wins; 248 is its centre. + const majority = makePng(10, 10, x => (x < 6 ? [255, 255, 255, 255] : [0, 0, 0, 255])); + expect((await new Bun.Image(majority).stats()).dominant).toEqual({ r: 248, g: 248, b: 248 }); + + // Two luma values at p=0.5 each → Shannon entropy of exactly 1 bit. + const even = makePng(8, 8, x => (x < 4 ? [255, 255, 255, 255] : [0, 0, 0, 255])); + expect((await new Bun.Image(even).stats()).entropy).toBe(1); + }); + + test("sharpness: a hard edge scores, a flat image doesn't", async () => { + const flat = makePng(8, 8, () => [128, 128, 128, 255]); + const edge = makePng(8, 8, x => (x < 4 ? [0, 0, 0, 255] : [255, 255, 255, 255])); + expect((await new Bun.Image(flat).stats()).sharpness).toBe(0); + expect((await new Bun.Image(edge).stats()).sharpness).toBeGreaterThan(10); + }); + + test("stats() describes the source — recorded ops are ignored", async () => { + const plain = await new Bun.Image(gradientPng).stats(); + const piped = await new Bun.Image(gradientPng).resize(2, 2).rotate(90).modulate({ brightness: 2 }).stats(); + expect(piped).toEqual(plain); + }); + + test("a recorded resize doesn't shrink the decode stats run on (JPEG IDCT hint)", async () => { + // JPEG decode honours a resize target via IDCT downscaling; stats must + // decode at full resolution regardless, or means/positions would be + // computed on a 1/8-scale image. + const jpeg = await new Bun.Image(makePng(64, 64, (x, y) => [(x * 4) & 255, (y * 4) & 255, (x ^ y) & 255, 255])) + .jpeg({ quality: 90 }) + .bytes(); + const plain = await new Bun.Image(jpeg).stats(); + const withResize = new Bun.Image(jpeg).resize(4, 4); + expect(await withResize.stats()).toEqual(plain); + // The dimension getters reflect the full source, not the resize target. + expect([withResize.width, withResize.height]).toEqual([64, 64]); + }); + + test("JPEG source: dominant lands in the right bin, alpha reads opaque", async () => { + const jpeg = await new Bun.Image(makePng(16, 16, () => [40, 136, 232, 255])).jpeg({ quality: 95 }).bytes(); + const s = await new Bun.Image(jpeg).stats(); + // JPEG is lossy — allow the neighbouring bin but nothing further. + expect(Math.abs(s.dominant.r - 40)).toBeLessThanOrEqual(16); + expect(Math.abs(s.dominant.g - 136)).toBeLessThanOrEqual(16); + expect(Math.abs(s.dominant.b - 232)).toBeLessThanOrEqual(16); + expect(s.isOpaque).toBe(true); + expect(s.channels[3].min).toBe(255); + }); + + test("width/height getters reflect source dimensions after stats()", async () => { + const img = new Bun.Image(gradientPng); + await img.stats(); + expect([img.width, img.height]).toEqual([16, 16]); + }); + + test("stats() respects maxPixels", async () => { + await expect(new Bun.Image(gradientPng, { maxPixels: 4 }).stats()).rejects.toMatchObject({ + code: "ERR_IMAGE_TOO_MANY_PIXELS", + }); + }); + + test("identical stats from clones sharing a decode", async () => { + const base = new Bun.Image(makePng(8, 8, () => [72, 72, 72, 255])); + const [s1, s2] = await Promise.all([base.clone().stats(), base.clone().stats()]); + expect(s1).toEqual(s2); + expect(s1.dominant).toEqual({ r: 72, g: 72, b: 72 }); + }); +});