Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8263,8 +8263,8 @@ declare module "bun" {
* `autoOrient → rotate → flip/flop → resize → modulate`.
*
* The source ICC colour profile (Display P3, Adobe RGB, Jpegli XYB, etc.)
* is preserved through re-encode to JPEG and PNG so non-sRGB images don't
* shift colour. WebP output drops the profile; `png()` / `jpeg()` keep it.
* is preserved through re-encode to JPEG, PNG, and WebP so non-sRGB
* images don't shift colour.
*
* @example
* ```ts
Expand Down
17 changes: 17 additions & 0 deletions scripts/build/deps/libwebp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
* libwebp — Google's reference WebP codec. Backs Bun.Image WebP
* decode/encode plus the SharpYUV RGB→YUV converter the encoder prefers.
*
* mux/demux are the RIFF-container helpers: demux reads VP8X chunks (ICCP,
* EXIF, XMP) out of an input WebP without touching the bitstream; mux
* wraps a raw VP8/VP8L encode in a VP8X container so those chunks can be
* attached on output. Only the ICCP chunk is used today (ICC profile
* carry-through for #30197), but the full mux/demux is linked since the
* TUs are tiny and the EXIF/XMP chunks will need the same plumbing later.
*
* DirectBuild: no config.h, no codegen. Every dsp/*_{sse2,sse41,neon,msa,
* mips}*.c file self-guards on WEBP_USE_<ISA> (derived from compiler arch
* macros in src/dsp/cpu.h), so the off-target ones compile to empty TUs —
Expand Down Expand Up @@ -68,6 +75,14 @@ const UTILS = [
"rescaler_utils", "thread_utils", "utils",
];

// RIFF container read/write — extracts/attaches the ICCP chunk so a
// non-sRGB source (Display P3, Adobe RGB, Jpegli XYB) keeps its colour
// meaning through a WebP re-encode. `anim_decode.c`/`anim_encode.c`
// (WebPAnimDecoder/WebPAnimEncoder) are omitted: they layer ON TOP of
// demux/mux, not the reverse, and Bun has no animated-WebP support.
const DEMUX = ["demux"];
const MUX = ["muxedit", "muxinternal", "muxread"];

// prettier-ignore
const SHARPYUV = [
"sharpyuv", "sharpyuv_cpu", "sharpyuv_csp", "sharpyuv_dsp",
Expand Down Expand Up @@ -113,6 +128,8 @@ export const libwebp: Dependency = {
...ENC.map(f => `src/enc/${f}.c`),
...DSP.map(f => simd(`src/dsp/${f}.c`, cfg.x64)),
...UTILS.map(f => `src/utils/${f}.c`),
...DEMUX.map(f => `src/demux/${f}.c`),
...MUX.map(f => `src/mux/${f}.c`),
...SHARPYUV.map(f => simd(`sharpyuv/${f}.c`, cfg.x64)),
],
// src/webp/*.h is the public API; internal headers use "src/..."
Expand Down
3 changes: 1 addition & 2 deletions src/image/Image.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1042,8 +1042,7 @@ pub const PipelineTask = struct {
// method). The pipeline doesn't colour-convert the RGBA, so dropping
// the profile reinterprets a non-sRGB source (Display-P3, Adobe RGB,
// Jpegli XYB) as sRGB and visibly shifts the colours — see #30197.
// Encoders that don't support ICC (WebP, at time of writing) ignore
// this field.
// JPEG/PNG/WebP embed it; HEIC/AVIF via the system backend do not.
if (enc.icc_profile == null) enc.icc_profile = decoded.icc_profile;
const out = codecs.encode(decoded.rgba, decoded.width, decoded.height, enc) catch |e| {
this.result = .{ .err = e };
Expand Down
127 changes: 124 additions & 3 deletions src/image/codec_webp.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,67 @@ extern fn WebPEncodeRGBA(rgba: [*]const u8, w: c_int, h: c_int, stride: c_int, q
extern fn WebPEncodeLosslessRGBA(rgba: [*]const u8, w: c_int, h: c_int, stride: c_int, out: *?[*]u8) usize;
pub extern fn WebPFree(ptr: ?*anyopaque) void;

// ─── libwebpmux / libwebpdemux ──────────────────────────────────────────────
// WebP carries colour profiles (and EXIF/XMP) in a VP8X RIFF container that
// wraps the VP8/VP8L bitstream. `WebPEncodeRGBA` only emits the bare
// bitstream chunk, and `WebPDecodeRGBA` only reads it — neither touches
// the surrounding chunks. To pull an ICCP chunk out of an input (decode)
// or to attach one to an output (encode) we go through the separate
// demux/mux APIs, which operate on the whole RIFF file. Both are
// statically linked from the same libwebp checkout.
//
// ABI version constants below are pinned to the libwebp commit in
// `scripts/build/deps/libwebp.ts` (v1.6.0). If that commit is bumped, check
// `src/webp/mux.h` / `demux.h` for `WEBP_{MUX,DEMUX}_ABI_VERSION` — the
// *Internal entry points reject a caller with a different major byte.
const WEBP_DEMUX_ABI_VERSION: c_int = 0x0107;
const WEBP_MUX_ABI_VERSION: c_int = 0x0109;
/// `WebPFormatFeature.WEBP_FF_FORMAT_FLAGS` — selector for `WebPDemuxGetI`
/// that returns the VP8X feature bitmask.
const WEBP_FF_FORMAT_FLAGS: c_int = 0;
/// `WebPFeatureFlags.ICCP_FLAG` — set when an ICCP chunk is present in the
/// VP8X container.
const ICCP_FLAG: u32 = 0x20;
/// `WebPMuxError.WEBP_MUX_OK` — the only non-error return from mux calls.
const WEBP_MUX_OK: c_int = 1;

/// `struct WebPData` — borrowed-bytes view used by both mux and demux.
/// Memory is `WebPMalloc`-owned when libwebp writes to it (e.g.
/// `WebPMuxAssemble` output) and caller-owned when libwebp reads it.
const WebPData = extern struct {
bytes: ?[*]const u8 = null,
size: usize = 0,
};

/// `struct WebPChunkIterator` — cursor into a VP8X chunk list. Only `chunk`
/// is read; `pad`/`private_` are libwebp-internal bookkeeping that
/// `WebPDemuxReleaseChunkIterator` walks. `chunk.bytes` is a borrowed view
/// INTO the original input buffer — dupe it out before `WebPDemuxDelete`.
const WebPChunkIterator = extern struct {
chunk_num: c_int,
num_chunks: c_int,
chunk: WebPData,
pad: [6]u32,
private_: ?*anyopaque,
};

const WebPDemuxer = opaque {};
const WebPMux = opaque {};

// `WebPDemux()` and `WebPMuxNew()` are `static inline` in the headers and
// just forward to these version-checked entry points with the ABI constant.
extern fn WebPDemuxInternal(data: *const WebPData, allow_partial: c_int, state: ?*c_int, version: c_int) ?*WebPDemuxer;
extern fn WebPDemuxDelete(dmux: ?*WebPDemuxer) void;
extern fn WebPDemuxGetI(dmux: *const WebPDemuxer, feature: c_int) u32;
extern fn WebPDemuxGetChunk(dmux: *const WebPDemuxer, fourcc: [*]const u8, chunk_number: c_int, iter: *WebPChunkIterator) c_int;
extern fn WebPDemuxReleaseChunkIterator(iter: *WebPChunkIterator) void;

extern fn WebPNewInternal(version: c_int) ?*WebPMux;
extern fn WebPMuxDelete(mux: ?*WebPMux) void;
extern fn WebPMuxSetImage(mux: *WebPMux, bitstream: *const WebPData, copy_data: c_int) c_int;
extern fn WebPMuxSetChunk(mux: *WebPMux, fourcc: [*]const u8, chunk_data: *const WebPData, copy_data: c_int) c_int;
extern fn WebPMuxAssemble(mux: *WebPMux, assembled_data: *WebPData) c_int;

pub fn decode(bytes: []const u8, max_pixels: u64) codecs.Error!codecs.Decoded {
var cw: c_int = 0;
var ch: c_int = 0;
Expand All @@ -29,19 +90,79 @@ pub fn decode(bytes: []const u8, max_pixels: u64) codecs.Error!codecs.Decoded {
if (cw != w or ch != h) return error.DecodeFailed;
const len: usize = @as(usize, w) * h * 4;
const out = try bun.default_allocator.dupe(u8, ptr[0..len]);
return .{ .rgba = out, .width = w, .height = h };
errdefer bun.default_allocator.free(out);

// Extract the ICCP chunk (if any) from the RIFF container. A plain
// VP8/VP8L WebP with no VP8X wrapper has no ICCP — `WebPDemux` still
// succeeds, `WEBP_FF_FORMAT_FLAGS` returns 0, and we skip the chunk
// walk. The chunk iterator hands back a borrowed view into `bytes`;
// dupe into `bun.default_allocator` to match JPEG/PNG ownership so the
// pipeline can free it uniformly. Propagate OutOfMemory on the dupe
// rather than silently dropping colour management — the pixels may be
// Display P3 / Adobe RGB / XYB where "no profile" reinterprets them as
// sRGB and visibly shifts colour, which is the exact bug #30197 is
// about. A failed demux (malformed container) falls through with
// `.icc_profile = null`; the pixels decoded fine so the image is still
// usable.
const icc: ?[]u8 = blk: {
const data: WebPData = .{ .bytes = bytes.ptr, .size = bytes.len };
const dmux = WebPDemuxInternal(&data, 0, null, WEBP_DEMUX_ABI_VERSION) orelse break :blk null;
defer WebPDemuxDelete(dmux);
if (WebPDemuxGetI(dmux, WEBP_FF_FORMAT_FLAGS) & ICCP_FLAG == 0) break :blk null;
var iter: WebPChunkIterator = std.mem.zeroes(WebPChunkIterator);
if (WebPDemuxGetChunk(dmux, "ICCP", 1, &iter) == 0) break :blk null;
defer WebPDemuxReleaseChunkIterator(&iter);
const p = iter.chunk.bytes orelse break :blk null;
if (iter.chunk.size == 0) break :blk null;
break :blk try bun.default_allocator.dupe(u8, p[0..iter.chunk.size]);
};
return .{ .rgba = out, .width = w, .height = h, .icc_profile = icc };
}

pub fn encode(rgba: []const u8, w: u32, h: u32, quality: u8, lossless: bool) codecs.Error!codecs.Encoded {
pub fn encode(rgba: []const u8, w: u32, h: u32, quality: u8, lossless: bool, icc_profile: ?[]const u8) codecs.Error!codecs.Encoded {
var out: ?[*]u8 = null;
const stride: c_int = @intCast(w * 4);
const len = if (lossless)
WebPEncodeLosslessRGBA(rgba.ptr, @intCast(w), @intCast(h), stride, &out)
else
WebPEncodeRGBA(rgba.ptr, @intCast(w), @intCast(h), stride, @floatFromInt(quality), &out);
if (len == 0 or out == null) return error.EncodeFailed;
return .{ .bytes = out.?[0..len], .free = codecs.Encoded.wrap(WebPFree) };
const bitstream = out.?[0..len];

// Fast path: no profile to attach, so the bare VP8/VP8L RIFF that
// `WebPEncodeRGBA` produced is already the final container. Avoids the
// mux round-trip (and its extra copy) for the common sRGB case.
const profile = icc_profile orelse
return .{ .bytes = bitstream, .free = codecs.Encoded.wrap(WebPFree) };
if (profile.len == 0)
return .{ .bytes = bitstream, .free = codecs.Encoded.wrap(WebPFree) };

// Wrap the bitstream in a VP8X container with an ICCP chunk. libwebpmux
// builds a new RIFF file from the image + chunk and allocates the
// assembled output via `WebPMalloc`; hand THAT buffer to JS with
// `WebPFree` as the finaliser and drop the intermediate encode. With
// `copy_data = 0` the mux borrows our buffers until `WebPMuxAssemble`
// returns, so `bitstream`/`profile` must outlive the assemble call
// (both do — `bitstream` is freed below, `profile` is caller-owned).
defer WebPFree(bitstream.ptr);
const mux = WebPNewInternal(WEBP_MUX_ABI_VERSION) orelse return error.OutOfMemory;
defer WebPMuxDelete(mux);
const img: WebPData = .{ .bytes = bitstream.ptr, .size = bitstream.len };
if (WebPMuxSetImage(mux, &img, 0) != WEBP_MUX_OK) return error.EncodeFailed;
const icc: WebPData = .{ .bytes = profile.ptr, .size = profile.len };
if (WebPMuxSetChunk(mux, "ICCP", &icc, 0) != WEBP_MUX_OK) return error.EncodeFailed;
var assembled: WebPData = .{};
if (WebPMuxAssemble(mux, &assembled) != WEBP_MUX_OK) {
// `WebPMuxAssemble` writes a half-built buffer into `assembled` even
// on failure; its contract says `WebPDataClear` (i.e. `WebPFree`) is
// safe to call on any return.
WebPFree(@constCast(assembled.bytes));
return error.EncodeFailed;
}
const assembled_ptr = @constCast(assembled.bytes orelse return error.EncodeFailed);
return .{ .bytes = assembled_ptr[0..assembled.size], .free = codecs.Encoded.wrap(WebPFree) };
}

const bun = @import("bun");
const codecs = @import("./codecs.zig");
const std = @import("std");
32 changes: 16 additions & 16 deletions src/image/codecs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -145,16 +145,16 @@ pub const Decoded = struct {
width: u32,
height: u32,
/// ICC color profile bytes pulled from the source container (JPEG APP2,
/// PNG iCCP), `bun.default_allocator`-owned. `null` when the source
/// didn't carry one or the decode path doesn't extract it — WebP
/// (libwebpmux/libwebpdemux not linked), BMP/GIF (no ICC chunk), and
/// system backends (which already colour-manage into sRGB during
/// decode, so the profile is no longer needed). The image pipeline
/// hands this straight to the matching encoder — the RGBA buffer is
/// NOT converted to sRGB, so the bytes only have their intended colour
/// meaning when the profile travels with them. Dropping it on a
/// Display-P3 / Adobe RGB / XYB source would reinterpret the values
/// as sRGB and visibly shift the colours. See issue #30197.
/// PNG iCCP, WebP ICCP), `bun.default_allocator`-owned. `null` when the
/// source didn't carry one or the decode path doesn't extract it —
/// BMP/GIF (no ICC chunk) and system backends (which already colour-
/// manage into sRGB during decode, so the profile is no longer
/// needed). The image pipeline hands this straight to the matching
/// encoder — the RGBA buffer is NOT converted to sRGB, so the bytes
/// only have their intended colour meaning when the profile travels
/// with them. Dropping it on a Display-P3 / Adobe RGB / XYB source
/// would reinterpret the values as sRGB and visibly shift the
/// colours. See issue #30197.
icc_profile: ?[]u8 = null,

pub fn deinit(self: *Decoded) void {
Expand Down Expand Up @@ -313,11 +313,11 @@ pub const EncodeOptions = struct {
dither: bool = false,
/// JPEG only: emit a progressive scan script (coarse-to-fine render).
progressive: bool = false,
/// ICC profile to embed in the output container (JPEG APP2, PNG iCCP).
/// `null` ⇒ no profile chunk/marker is written. The pipeline forwards
/// this from the decode step so a non-sRGB source (P3, Adobe RGB,
/// XYB/Jpegli) preserves its colour meaning through re-encode. Borrowed;
/// the caller retains ownership.
/// ICC profile to embed in the output container (JPEG APP2, PNG iCCP,
/// WebP ICCP). `null` ⇒ no profile chunk/marker is written. The
/// pipeline forwards this from the decode step so a non-sRGB source
/// (P3, Adobe RGB, XYB/Jpegli) preserves its colour meaning through
/// re-encode. Borrowed; the caller retains ownership.
icc_profile: ?[]const u8 = null,
};

Expand Down Expand Up @@ -363,7 +363,7 @@ pub fn encode(rgba: []const u8, width: u32, height: u32, opts: EncodeOptions) Er
png.encodeIndexed(rgba, width, height, opts.compression_level, opts.colors, opts.dither, opts.icc_profile)
else
png.encode(rgba, width, height, opts.compression_level, opts.icc_profile),
.webp => webp.encode(rgba, width, height, opts.quality, opts.lossless),
.webp => webp.encode(rgba, width, height, opts.quality, opts.lossless, opts.icc_profile),
// Same routing rationale as decode(): the OS encoder is a capability
// fallback, not a fast path — ImageIO's quality scale doesn't match
// libjpeg-turbo's, and it can't honour compressionLevel/palette/
Expand Down
Loading
Loading