diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index c66ed0e6917..d341e0d3cbe 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -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 diff --git a/scripts/build/deps/libwebp.ts b/scripts/build/deps/libwebp.ts index bc3a0653773..8c185c0069f 100644 --- a/scripts/build/deps/libwebp.ts +++ b/scripts/build/deps/libwebp.ts @@ -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_ (derived from compiler arch * macros in src/dsp/cpu.h), so the off-target ones compile to empty TUs — @@ -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", @@ -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/..." diff --git a/src/image/Image.zig b/src/image/Image.zig index 34526029cfb..159e6e12ad8 100644 --- a/src/image/Image.zig +++ b/src/image/Image.zig @@ -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 }; diff --git a/src/image/codec_webp.zig b/src/image/codec_webp.zig index 4aeda368889..d595ea3c931 100644 --- a/src/image/codec_webp.zig +++ b/src/image/codec_webp.zig @@ -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; @@ -29,10 +90,36 @@ 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) @@ -40,8 +127,42 @@ pub fn encode(rgba: []const u8, w: u32, h: u32, quality: u8, lossless: bool) cod 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"); diff --git a/src/image/codecs.zig b/src/image/codecs.zig index 9cf5409a7e7..4c0fa13fe77 100644 --- a/src/image/codecs.zig +++ b/src/image/codecs.zig @@ -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 { @@ -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, }; @@ -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/ diff --git a/test/js/bun/image/image.test.ts b/test/js/bun/image/image.test.ts index 92af685490a..c5a7ffbf4bb 100644 --- a/test/js/bun/image/image.test.ts +++ b/test/js/bun/image/image.test.ts @@ -427,7 +427,7 @@ describe("Bun.Image", () => { // XYB) as sRGB and visibly shifts the colours. Bun's contract here is: // source-format re-encode preserves the profile; format conversion // preserves it when the target container supports ICC (JPEG APP2, PNG - // iCCP). WebP drops it — libwebpmux isn't in the build. + // iCCP, WebP ICCP). describe("ICC profile", () => { // Splice an iCCP chunk carrying `profile` into a valid PNG. The PNG spec // requires iCCP before the first IDAT; put it right after IHDR. Chunk @@ -486,6 +486,25 @@ describe("Bun.Image", () => { return pieces.length === 0 ? null : Buffer.concat(pieces); } + // Walk a WebP RIFF container and return the ICCP chunk payload. + // Layout: "RIFF" · u32le(riff_size) · "WEBP" · { fourcc(4) · + // u32le(chunk_size) · payload · pad-to-even }… A non-VP8X WebP + // (plain `WebPEncodeRGBA` output) only has a single VP8/VP8L chunk + // and returns null here. libwebpmux wraps the bitstream in VP8X + + // ICCP when a profile is attached. + function extractWebpIccp(webp: Uint8Array): Uint8Array | null { + if (webp.length < 12) return null; + const dv = new DataView(webp.buffer, webp.byteOffset, webp.byteLength); + let off = 12; // past RIFF····WEBP + while (off + 8 <= webp.length) { + const fourcc = String.fromCharCode(webp[off], webp[off + 1], webp[off + 2], webp[off + 3]); + const size = dv.getUint32(off + 4, /* littleEndian */ true); + if (fourcc === "ICCP") return webp.slice(off + 8, off + 8 + size); + off += 8 + size + (size & 1); // RIFF pads odd-length chunks to even + } + return null; + } + // Distinctive binary payload that round-trips through libspng's // deflate/inflate and libjpeg-turbo's APP2 chunking without // modification. Neither library validates ICC internals. The wide byte @@ -626,6 +645,82 @@ describe("Bun.Image", () => { expect(got).not.toBeNull(); expect(Array.from(got!)).toEqual(Array.from(fakeProfile)); }); + + // ── WebP ICCP carry-through — libwebpmux/libwebpdemux wrap the raw + // VP8/VP8L bitstream in a VP8X container so the ICCP chunk can travel + // alongside. Covers decode-side (WebP→PNG), encode-side (PNG→WebP, + // both lossy and lossless), WebP→WebP round-trip, geometry ops + // preserving the profile through a WebP encode, and the no-profile + // fast path staying a bare VP8/VP8L RIFF. + + test("PNG iCCP transfers to WebP encode — ICCP chunk in output", async () => { + const src = pngWithIccp(cornersPng, fakeProfile); + const webp = await new Bun.Image(src).webp({ quality: 90 }).bytes(); + // libwebpmux wraps the bitstream in VP8X when any extended chunk is + // attached. First chunk after the RIFF header must be VP8X with the + // ICCP flag (bit 5 of byte 0 of the VP8X body) set. + expect(String.fromCharCode(...webp.subarray(12, 16))).toBe("VP8X"); + expect(webp[20] & 0x20).toBe(0x20); + const got = extractWebpIccp(webp); + expect(got).not.toBeNull(); + expect(Array.from(got!)).toEqual(Array.from(fakeProfile)); + }); + + test("PNG iCCP transfers to lossless WebP encode", async () => { + // VP8L has a different bitstream chunk from VP8; the VP8X + ICCP + // wrapping must still work. Lossless path is separate from lossy + // (`WebPEncodeLosslessRGBA` vs `WebPEncodeRGBA`), so both need + // coverage. + const src = pngWithIccp(cornersPng, fakeProfile); + const webp = await new Bun.Image(src).webp({ lossless: true }).bytes(); + const got = extractWebpIccp(webp); + expect(got).not.toBeNull(); + expect(Array.from(got!)).toEqual(Array.from(fakeProfile)); + }); + + test("WebP ICCP transfers to PNG encode — demux extracts the profile", async () => { + // Build the WebP via Bun.Image (PNG-with-iCCP → WebP) so the test + // doesn't hand-assemble a VP8X container, then decode THAT WebP + // and re-encode to PNG. This exercises `WebPDemuxGetChunk` on the + // decode side. + const srcPng = pngWithIccp(cornersPng, fakeProfile); + const webp = await new Bun.Image(srcPng).webp({ lossless: true }).bytes(); + const outPng = await new Bun.Image(webp).png().bytes(); + const got = extractPngIccp(outPng); + expect(got).not.toBeNull(); + expect(Array.from(got!)).toEqual(Array.from(fakeProfile)); + }); + + test("WebP → WebP re-encode preserves the ICC profile byte-for-byte", async () => { + const srcPng = pngWithIccp(cornersPng, fakeProfile); + const webp = await new Bun.Image(srcPng).webp({ quality: 90 }).bytes(); + const reWebp = await new Bun.Image(webp).webp({ quality: 90 }).bytes(); + const got = extractWebpIccp(reWebp); + expect(got).not.toBeNull(); + expect(Array.from(got!)).toEqual(Array.from(fakeProfile)); + }); + + test("WebP ICCP survives resize — geometry doesn't drop profile", async () => { + const src = pngWithIccp(cornersPng, fakeProfile); + const webp = await new Bun.Image(src).resize(8, 6).webp({ quality: 90 }).bytes(); + const got = extractWebpIccp(webp); + expect(got).not.toBeNull(); + expect(Array.from(got!)).toEqual(Array.from(fakeProfile)); + }); + + test("WebP without ICCP stays a bare VP8/VP8L — no VP8X wrapper added", async () => { + // The no-profile fast path skips the mux round-trip entirely. A + // bare lossy WebP is RIFF····WEBP·VP8 ···; a lossless one is + // ···VP8L···. Neither should gain a VP8X chunk or an ICCP chunk + // when the source carried no profile. + const lossy = await new Bun.Image(cornersPng).webp({ quality: 90 }).bytes(); + expect(String.fromCharCode(...lossy.subarray(12, 16))).toBe("VP8 "); + expect(extractWebpIccp(lossy)).toBeNull(); + + const lossless = await new Bun.Image(cornersPng).webp({ lossless: true }).bytes(); + expect(String.fromCharCode(...lossless.subarray(12, 16))).toBe("VP8L"); + expect(extractWebpIccp(lossless)).toBeNull(); + }); }); // EXIF: build a minimal JPEG via Bun.Image, then splice in an APP1 segment