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
30 changes: 30 additions & 0 deletions docs/runtime/image.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}

/**
Expand Down Expand Up @@ -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°. */
Expand Down Expand Up @@ -8431,6 +8515,21 @@ declare module "bun" {
toBase64(): Promise<string>;
/** Decode just enough to read width/height/format. */
metadata(): Promise<Image.Metadata>;
/**
* 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<Image.Stats>;

/** Populated after the first awaited terminal; `-1` before. */
readonly width: number;
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/image/Image.classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -64,6 +68,9 @@ export default [
// <img src> / 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" },
Expand Down
Loading
Loading