From 251437bb9a212d9ea8ddfe7fd5544b2c706294a9 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:52:32 -0400 Subject: [PATCH 1/6] cicp --- lib/index.d.ts | 31 +++++++++++++++++++++++++++++++ lib/input.js | 6 ++++++ lib/output.js | 24 +++++++++++++++++++++++- src/common.cc | 12 ++++++++++++ src/common.h | 8 ++++++++ src/metadata.cc | 13 +++++++++++++ src/metadata.h | 10 +++++++++- src/pipeline.cc | 20 ++++++++++++++++++++ src/pipeline.h | 8 ++++++++ test/unit/metadata.js | 4 ++++ 10 files changed, 134 insertions(+), 2 deletions(-) diff --git a/lib/index.d.ts b/lib/index.d.ts index fc0b2d8ef..3f976f672 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -337,6 +337,13 @@ declare namespace sharp { */ keepMetadata(): Sharp; + /** + * Keep CICP (Coding-Independent Code Points) colour metadata from the input image. + * When set, CICP-tagged images will pass through without linearization. + * @returns A sharp instance that can be used to chain operations + */ + keepCicp(): Sharp; + /** * Access to pixel-derived image statistics for every channel in the image. * @returns A sharp instance that can be used to chain operations @@ -1070,6 +1077,14 @@ declare namespace sharp { premultiplied?: boolean | undefined; /** The height of each page/frame for animated images, must be an integral factor of the overall image height. */ pageHeight?: number | undefined; + /** CICP colour primaries (ITU-T H.273). */ + cicpColourPrimaries?: number | undefined; + /** CICP transfer characteristics (ITU-T H.273). */ + cicpTransferCharacteristics?: number | undefined; + /** CICP matrix coefficients (ITU-T H.273). */ + cicpMatrixCoefficients?: number | undefined; + /** CICP full range flag (ITU-T H.273). */ + cicpFullRangeFlag?: number | undefined; } type CreateChannels = 3 | 4; @@ -1273,6 +1288,14 @@ declare namespace sharp { comments?: CommentsMetadata[] | undefined; /** HDR gain map, if present */ gainMap?: GainMapMetadata | undefined; + /** CICP colour primaries (ITU-T H.273), if present. */ + cicpColourPrimaries?: number | undefined; + /** CICP transfer characteristics (ITU-T H.273), if present. */ + cicpTransferCharacteristics?: number | undefined; + /** CICP matrix coefficients (ITU-T H.273), if present. */ + cicpMatrixCoefficients?: number | undefined; + /** CICP full range flag (ITU-T H.273), if present. */ + cicpFullRangeFlag?: number | undefined; } interface LevelMetadata { @@ -1774,6 +1797,14 @@ declare namespace sharp { pages?: number | undefined; /** Number of pixels high each page in a multi-page image will be. */ pageHeight?: number | undefined; + /** CICP colour primaries (ITU-T H.273), if present. */ + cicpColourPrimaries?: number | undefined; + /** CICP transfer characteristics (ITU-T H.273), if present. */ + cicpTransferCharacteristics?: number | undefined; + /** CICP matrix coefficients (ITU-T H.273), if present. */ + cicpMatrixCoefficients?: number | undefined; + /** CICP full range flag (ITU-T H.273), if present. */ + cicpFullRangeFlag?: number | undefined; } interface AvailableFormatInfo { diff --git a/lib/input.js b/lib/input.js index 695f6d392..33dd9dbce 100644 --- a/lib/input.js +++ b/lib/input.js @@ -229,6 +229,12 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { throw is.invalidParameterError('raw.pageHeight', 'positive integer', inputOptions.raw.pageHeight); } } + if (is.defined(inputOptions.raw.cicpColourPrimaries)) { + inputDescriptor.rawCicpColourPrimaries = inputOptions.raw.cicpColourPrimaries; + inputDescriptor.rawCicpTransferCharacteristics = inputOptions.raw.cicpTransferCharacteristics; + inputDescriptor.rawCicpMatrixCoefficients = inputOptions.raw.cicpMatrixCoefficients; + inputDescriptor.rawCicpFullRangeFlag = inputOptions.raw.cicpFullRangeFlag; + } } // Multi-page input (GIF, TIFF, PDF) if (is.defined(inputOptions.animated)) { diff --git a/lib/output.js b/lib/output.js index 2b949a4ed..50d4ed6c7 100644 --- a/lib/output.js +++ b/lib/output.js @@ -470,7 +470,28 @@ function withXmp (xmp) { * @returns {Sharp} */ function keepMetadata () { - this.options.keepMetadata = 0b11111; + this.options.keepMetadata = 0b1111111; + return this; +} + +/** + * Keep CICP (Coding-Independent Code Points) colour metadata from the input image. + * + * CICP metadata describes colour primaries, transfer characteristics, + * and matrix coefficients per ITU-T H.273. When set, CICP-tagged images + * will pass through without linearization, preserving the original + * signal encoding (e.g. PQ, HLG). + * + * @example + * const output = await sharp('hdr-input.avif') + * .keepCicp() + * .jxl() + * .toBuffer(); + * + * @returns {Sharp} + */ +function keepCicp () { + this.options.keepMetadata |= 0b1000000; return this; } @@ -1745,6 +1766,7 @@ module.exports = (Sharp) => { keepXmp, withXmp, keepMetadata, + keepCicp, withMetadata, toFormat, jpeg, diff --git a/src/common.cc b/src/common.cc index 4b1f1c468..7ce694c6a 100644 --- a/src/common.cc +++ b/src/common.cc @@ -98,6 +98,12 @@ namespace sharp { descriptor->rawHeight = AttrAsUint32(input, "rawHeight"); descriptor->rawPremultiplied = AttrAsBool(input, "rawPremultiplied"); descriptor->rawPageHeight = AttrAsUint32(input, "rawPageHeight"); + if (HasAttr(input, "rawCicpColourPrimaries")) { + descriptor->rawCicpColourPrimaries = AttrAsInt32(input, "rawCicpColourPrimaries"); + descriptor->rawCicpTransferCharacteristics = AttrAsInt32(input, "rawCicpTransferCharacteristics"); + descriptor->rawCicpMatrixCoefficients = AttrAsInt32(input, "rawCicpMatrixCoefficients"); + descriptor->rawCicpFullRangeFlag = AttrAsInt32(input, "rawCicpFullRangeFlag"); + } } // Multi-page input (GIF, TIFF, PDF) if (HasAttr(input, "pages")) { @@ -496,6 +502,12 @@ namespace sharp { if (descriptor->rawPremultiplied) { image = image.unpremultiply(); } + if (descriptor->rawCicpColourPrimaries >= 0) { + image.set("cicp-colour-primaries", descriptor->rawCicpColourPrimaries); + image.set("cicp-transfer-characteristics", descriptor->rawCicpTransferCharacteristics); + image.set("cicp-matrix-coefficients", descriptor->rawCicpMatrixCoefficients); + image.set("cicp-full-range-flag", descriptor->rawCicpFullRangeFlag); + } imageType = ImageType::RAW; } else { // Compressed data diff --git a/src/common.h b/src/common.h index e43856b52..a2a496529 100644 --- a/src/common.h +++ b/src/common.h @@ -52,6 +52,10 @@ namespace sharp { int rawHeight; bool rawPremultiplied; int rawPageHeight; + int rawCicpColourPrimaries; + int rawCicpTransferCharacteristics; + int rawCicpMatrixCoefficients; + int rawCicpFullRangeFlag; int pages; int page; int createChannels; @@ -104,6 +108,10 @@ namespace sharp { rawHeight(0), rawPremultiplied(false), rawPageHeight(0), + rawCicpColourPrimaries(-1), + rawCicpTransferCharacteristics(-1), + rawCicpMatrixCoefficients(-1), + rawCicpFullRangeFlag(-1), pages(1), page(-1), createChannels(0), diff --git a/src/metadata.cc b/src/metadata.cc index f5b349704..f7c754f76 100644 --- a/src/metadata.cc +++ b/src/metadata.cc @@ -149,6 +149,13 @@ class MetadataWorker : public Napi::AsyncWorker { memcpy(baton->gainMap, gainMap, gainMapLength); baton->gainMapLength = gainMapLength; } + // CICP colour metadata + if (image.get_typeof("cicp-colour-primaries") == G_TYPE_INT) { + baton->cicpColourPrimaries = image.get_int("cicp-colour-primaries"); + baton->cicpTransferCharacteristics = image.get_int("cicp-transfer-characteristics"); + baton->cicpMatrixCoefficients = image.get_int("cicp-matrix-coefficients"); + baton->cicpFullRangeFlag = image.get_int("cicp-full-range-flag"); + } // PNG comments vips_image_map(image.get_image(), readPNGComment, &baton->comments); // Media type @@ -333,6 +340,12 @@ class MetadataWorker : public Napi::AsyncWorker { gainMap.Set("image", Napi::Buffer::NewOrCopy(env, baton->gainMap, baton->gainMapLength, sharp::FreeCallback)); } + if (baton->cicpColourPrimaries >= 0) { + info.Set("cicpColourPrimaries", baton->cicpColourPrimaries); + info.Set("cicpTransferCharacteristics", baton->cicpTransferCharacteristics); + info.Set("cicpMatrixCoefficients", baton->cicpMatrixCoefficients); + info.Set("cicpFullRangeFlag", baton->cicpFullRangeFlag); + } if (baton->comments.size() > 0) { int i = 0; Napi::Array comments = Napi::Array::New(env, baton->comments.size()); diff --git a/src/metadata.h b/src/metadata.h index d1781dc57..65dd56ba8 100644 --- a/src/metadata.h +++ b/src/metadata.h @@ -57,6 +57,10 @@ struct MetadataBaton { char *gainMap; size_t gainMapLength; MetadataComments comments; + int cicpColourPrimaries; + int cicpTransferCharacteristics; + int cicpMatrixCoefficients; + int cicpFullRangeFlag; std::string err; MetadataBaton(): @@ -87,7 +91,11 @@ struct MetadataBaton { tifftagPhotoshop(nullptr), tifftagPhotoshopLength(0), gainMap(nullptr), - gainMapLength(0) {} + gainMapLength(0), + cicpColourPrimaries(-1), + cicpTransferCharacteristics(-1), + cicpMatrixCoefficients(-1), + cicpFullRangeFlag(-1) {} }; Napi::Value metadata(const Napi::CallbackInfo& info); diff --git a/src/pipeline.cc b/src/pipeline.cc index 9cd96e926..1fa24802a 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -363,6 +363,13 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("intent", VIPS_INTENT_PERCEPTUAL)); } + // CICP linearization for colour-accurate processing. + // Skip if keepCicp is set (passthrough mode). + if (image.get_typeof("cicp-transfer-characteristics") == G_TYPE_INT && + !(baton->keepMetadata & VIPS_FOREIGN_KEEP_CICP)) { + image = image.CICP2scRGB(); + } + // Flatten image to remove alpha channel if (baton->flatten && image.has_alpha()) { image = sharp::Flatten(image, baton->flattenBackground); @@ -1083,6 +1090,13 @@ class PipelineWorker : public Napi::AsyncWorker { // Cast pixels to requested format image = image.cast(baton->rawDepth); } + // Preserve CICP metadata through raw round-trip + if (image.get_typeof("cicp-colour-primaries") == G_TYPE_INT) { + baton->cicpColourPrimaries = image.get_int("cicp-colour-primaries"); + baton->cicpTransferCharacteristics = image.get_int("cicp-transfer-characteristics"); + baton->cicpMatrixCoefficients = image.get_int("cicp-matrix-coefficients"); + baton->cicpFullRangeFlag = image.get_int("cicp-full-range-flag"); + } // Get raw image data baton->bufferOut = static_cast(image.write_to_memory(&baton->bufferOutLength)); if (baton->bufferOut == nullptr) { @@ -1345,6 +1359,12 @@ class PipelineWorker : public Napi::AsyncWorker { info.Set("pageHeight", static_cast(baton->pageHeightOut)); info.Set("pages", static_cast(baton->pagesOut)); } + if (baton->cicpColourPrimaries >= 0) { + info.Set("cicpColourPrimaries", baton->cicpColourPrimaries); + info.Set("cicpTransferCharacteristics", baton->cicpTransferCharacteristics); + info.Set("cicpMatrixCoefficients", baton->cicpMatrixCoefficients); + info.Set("cicpFullRangeFlag", baton->cicpFullRangeFlag); + } if (baton->bufferOutLength > 0) { info.Set("size", static_cast(baton->bufferOutLength)); diff --git a/src/pipeline.h b/src/pipeline.h index dfd503939..2b0f3d9da 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -203,6 +203,10 @@ struct PipelineBaton { int jxlEffort; bool jxlLossless; VipsBandFormat rawDepth; + int cicpColourPrimaries; + int cicpTransferCharacteristics; + int cicpMatrixCoefficients; + int cicpFullRangeFlag; std::string err; bool errUseWarning; int keepMetadata; @@ -385,6 +389,10 @@ struct PipelineBaton { jxlEffort(7), jxlLossless(false), rawDepth(VIPS_FORMAT_UCHAR), + cicpColourPrimaries(-1), + cicpTransferCharacteristics(-1), + cicpMatrixCoefficients(-1), + cicpFullRangeFlag(-1), errUseWarning(false), keepMetadata(0), withMetadataOrientation(-1), diff --git a/test/unit/metadata.js b/test/unit/metadata.js index f8b699ec8..a106d8776 100644 --- a/test/unit/metadata.js +++ b/test/unit/metadata.js @@ -989,6 +989,10 @@ describe('Image metadata', () => { compression: 'av1', hasProfile: false, hasAlpha: false, + cicpColourPrimaries: 1, + cicpTransferCharacteristics: 13, + cicpMatrixCoefficients: 6, + cicpFullRangeFlag: 1, autoOrient: { width: 2048, height: 858 From d077ce76c5712ae734c993628a38c8a25f05a10a Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:06:16 -0400 Subject: [PATCH 2/6] skip color space conversion --- src/pipeline.cc | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/pipeline.cc b/src/pipeline.cc index 1fa24802a..006bc7690 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -78,7 +78,17 @@ class PipelineWorker : public Napi::AsyncWorker { } } VipsAccess access = baton->input->access; - image = sharp::EnsureColourspace(image, baton->colourspacePipeline); + bool const hasCicp = image.get_typeof("cicp-transfer-characteristics") == G_TYPE_INT; + bool const cicpLinearized = hasCicp && baton->colourspacePipeline != VIPS_INTERPRETATION_LAST && + !(baton->keepMetadata & VIPS_FOREIGN_KEEP_CICP); + if (cicpLinearized) { + // User explicitly requested a pipeline colourspace — linearize + // via CICP2scRGB first, then convert to the requested space. + image = image.CICP2scRGB(); + image = sharp::EnsureColourspace(image, baton->colourspacePipeline); + } else if (!hasCicp) { + image = sharp::EnsureColourspace(image, baton->colourspacePipeline); + } int nPages = baton->input->pages; if (nPages == -1) { @@ -363,13 +373,6 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("intent", VIPS_INTENT_PERCEPTUAL)); } - // CICP linearization for colour-accurate processing. - // Skip if keepCicp is set (passthrough mode). - if (image.get_typeof("cicp-transfer-characteristics") == G_TYPE_INT && - !(baton->keepMetadata & VIPS_FOREIGN_KEEP_CICP)) { - image = image.CICP2scRGB(); - } - // Flatten image to remove alpha channel if (baton->flatten && image.has_alpha()) { image = sharp::Flatten(image, baton->flattenBackground); @@ -811,7 +814,8 @@ class PipelineWorker : public Napi::AsyncWorker { if (sharp::Is16Bit(image.interpretation())) { image = image.cast(VIPS_FORMAT_USHORT); } - if (image.interpretation() != baton->colourspace) { + // Skip for CICP images unless linearized via pipelineColorspace. + if ((!hasCicp || cicpLinearized) && image.interpretation() != baton->colourspace) { image = image.colourspace(baton->colourspace, VImage::option()->set("source_space", image.interpretation())); if (inputProfile.first != nullptr && baton->withIccProfile.empty()) { image = sharp::SetProfile(image, inputProfile); @@ -838,8 +842,8 @@ class PipelineWorker : public Napi::AsyncWorker { .copy(VImage::option()->set("interpretation", colourspace)); } - // Apply output ICC profile - if (!baton->withIccProfile.empty()) { + // Apply output ICC profile (skip for CICP unless linearized) + if ((!hasCicp || cicpLinearized) && !baton->withIccProfile.empty()) { try { image = image.icc_transform(const_cast(baton->withIccProfile.data()), VImage::option() ->set("input_profile", processingProfile) From c905a794994c8620139d1f7bf75576476f5e7661 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:32:01 -0400 Subject: [PATCH 3/6] tests --- test/fixtures/hdr-pq-bt2020.avif | Bin 0 -> 21504 bytes test/fixtures/index.js | 1 + test/unit/cicp.js | 310 +++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 test/fixtures/hdr-pq-bt2020.avif create mode 100644 test/unit/cicp.js diff --git a/test/fixtures/hdr-pq-bt2020.avif b/test/fixtures/hdr-pq-bt2020.avif new file mode 100644 index 0000000000000000000000000000000000000000..249eee7b2511b00b0512a4f991b67da150fba7da GIT binary patch literal 21504 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 { + describe('metadata', () => { + it('should extract CICP metadata from HDR AVIF', async () => { + const meta = await sharp(fixtures.inputAvifHdr).metadata(); + assert.strictEqual(meta.cicpColourPrimaries, 9); + assert.strictEqual(meta.cicpTransferCharacteristics, 16); + assert.strictEqual(meta.cicpMatrixCoefficients, 9); + assert.strictEqual(meta.cicpFullRangeFlag, 1); + assert.strictEqual(meta.bitsPerSample, 12); + }); + + it('should not have CICP metadata on SDR images', async () => { + const meta = await sharp(fixtures.inputJpg).metadata(); + assert.strictEqual(meta.cicpColourPrimaries, undefined); + assert.strictEqual(meta.cicpTransferCharacteristics, undefined); + }); + }); + + describe('passthrough', () => { + it('should preserve PQ pixel values exactly', async () => { + const { data, info } = await sharp(fixtures.inputAvifHdr) + .raw({ depth: 'ushort' }) + .toBuffer({ resolveWithObject: true }); + assert.strictEqual(info.depth, 'ushort'); + assert.strictEqual(info.width, 512); + assert.strictEqual(info.height, 512); + const buf = Buffer.from(data); + assertPixelNear(readUshortPixel(buf, 512, PQ.greyBg.x, PQ.greyBg.y), PQ.greyBg.rgb, 0, 'grey bg'); + assertPixelNear(readUshortPixel(buf, 512, PQ.whiteBox.x, PQ.whiteBox.y), PQ.whiteBox.rgb, 0, 'white box'); + assertPixelNear(readUshortPixel(buf, 512, PQ.red.x, PQ.red.y), PQ.red.rgb, 0, 'red patch'); + }); + + it('should preserve PQ values after resize', async () => { + const { data, info } = await sharp(fixtures.inputAvifHdr) + .resize(256, 256, { fit: 'inside' }) + .raw({ depth: 'ushort' }) + .toBuffer({ resolveWithObject: true }); + assert.strictEqual(info.depth, 'ushort'); + assert.strictEqual(info.width, 256); + const buf = Buffer.from(data); + // Flat-colour areas are unchanged by resize; coordinates scaled from 512 + assertPixelNear(readUshortPixel(buf, 256, 128, 5), [33296, 33296, 33296], 100, 'grey bg'); + assertPixelNear(readUshortPixel(buf, 256, 43, 215), [30272, 17776, 11568], 100, 'red patch'); + assertPixelNear(readUshortPixel(buf, 256, 128, 215), [26288, 32736, 18960], 100, 'green patch'); + assertPixelNear(readUshortPixel(buf, 256, 214, 215), [15848, 10470, 32944], 200, 'blue patch'); + }); + + it('should preserve CICP metadata in raw output info', async () => { + const { info } = await sharp(fixtures.inputAvifHdr) + .raw({ depth: 'ushort' }) + .toBuffer({ resolveWithObject: true }); + assert.strictEqual(info.cicpColourPrimaries, 9); + assert.strictEqual(info.cicpTransferCharacteristics, 16); + assert.strictEqual(info.cicpMatrixCoefficients, 9); + assert.strictEqual(info.cicpFullRangeFlag, 1); + }); + + it('should preserve 100-nit text and background values', async () => { + const { data } = await sharp(fixtures.inputAvifHdr) + .raw({ depth: 'ushort' }) + .toBuffer({ resolveWithObject: true }); + const buf = Buffer.from(data); + assertPixelNear(readUshortPixel(buf, 512, PQ.greyBg.x, PQ.greyBg.y), PQ.greyBg.rgb, 0, 'grey bg'); + assertPixelNear(readUshortPixel(buf, 512, PQ.text100nit.x, PQ.text100nit.y), PQ.text100nit.rgb, 0, '100nit text'); + }); + + it('should preserve P3 text and colour patch values', async () => { + const { data } = await sharp(fixtures.inputAvifHdr) + .raw({ depth: 'ushort' }) + .toBuffer({ resolveWithObject: true }); + const buf = Buffer.from(data); + assertPixelNear(readUshortPixel(buf, 512, PQ.red.x, PQ.red.y), PQ.red.rgb, 0, 'red patch'); + assertPixelNear(readUshortPixel(buf, 512, PQ.redP3text.x, PQ.redP3text.y), PQ.redP3text.rgb, 0, 'P3 text on red'); + }); + + it('should produce correct JXL with PQ colour patches', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .jxl({ effort: 3 }) + .toBuffer(); + const { data } = await sharp(buf).raw({ depth: 'ushort' }).toBuffer({ resolveWithObject: true }); + const rbuf = Buffer.from(data); + // Default JXL passthrough — lossy noise but close to original PQ values + assertPixelNear(readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), [33337, 33339, 33345], 500, 'grey bg'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), [28517, 18293, 12665], 2000, 'red patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.green.x, PQ.green.y), [27560, 32597, 20250], 2000, 'green patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.blue.x, PQ.blue.y), [14676, 10494, 31570], 2000, 'blue patch'); + }); + }); + + describe('keepCicp', () => { + it('should preserve CICP metadata in JXL', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .jxl({ effort: 3 }) + .toBuffer(); + const meta = await sharp(buf).metadata(); + assert.strictEqual(meta.cicpColourPrimaries, 9); + assert.strictEqual(meta.cicpTransferCharacteristics, 16); + }); + + it('should preserve CICP metadata in AVIF', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .avif({ quality: 80 }) + .toBuffer(); + const meta = await sharp(buf).metadata(); + assert.strictEqual(meta.cicpColourPrimaries, 9); + assert.strictEqual(meta.cicpTransferCharacteristics, 16); + }); + + it('should preserve PQ colour values in lossy JXL', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .jxl({ effort: 3 }) + .toBuffer(); + const { data } = await sharp(buf).raw({ depth: 'ushort' }).toBuffer({ resolveWithObject: true }); + const rbuf = Buffer.from(data); + // JXL colour management shifts values from the original PQ encoding. + // These reference values are from keepCicp lossy JXL round-trip. + assertPixelNear(readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), [40004, 40006, 40005], 500, 'grey bg'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), [40609, 7937, 4010], 500, 'red patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.green.x, PQ.green.y), [18954, 41217, 11503], 500, 'green patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.blue.x, PQ.blue.y), [0, 0, 42025], 500, 'blue patch'); + }); + + it('should produce UHDR JPEG with gainmap', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .resize(64, 64, { fit: 'inside' }) + .jpeg({ quality: 80 }) + .toBuffer(); + const meta = await sharp(buf).metadata(); + assert.ok(meta.gainMap, 'expected gainmap in UHDR JPEG'); + }); + + it('should override pipelineColorspace', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .pipelineColorspace('scrgb') + .jxl({ effort: 3 }) + .toBuffer(); + const meta = await sharp(buf).metadata(); + assert.strictEqual(meta.cicpTransferCharacteristics, 16); + }); + + it('should override withIccProfile', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .withIccProfile('p3') + .avif({ quality: 80 }) + .toBuffer(); + const meta = await sharp(buf).metadata(); + assert.strictEqual(meta.cicpTransferCharacteristics, 16); + assert.strictEqual(meta.hasProfile, false); + }); + + it('should have no effect on non-CICP images', async () => { + const { data: with_ } = await sharp(fixtures.inputJpg) + .keepCicp() + .resize(64, 64) + .raw() + .toBuffer({ resolveWithObject: true }); + const { data: without } = await sharp(fixtures.inputJpg) + .resize(64, 64) + .raw() + .toBuffer({ resolveWithObject: true }); + assert.deepStrictEqual(with_, without); + }); + }); + + describe('raw round-trip', () => { + it('should carry CICP metadata through raw decode and re-encode', async () => { + const { data, info } = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .raw({ depth: 'ushort' }) + .toBuffer({ resolveWithObject: true }); + + assert.strictEqual(info.cicpColourPrimaries, 9); + assert.strictEqual(info.cicpTransferCharacteristics, 16); + + const buf = await sharp(data, { raw: info }) + .keepCicp() + .jxl({ effort: 3 }) + .toBuffer(); + const meta = await sharp(buf).metadata(); + assert.strictEqual(meta.cicpColourPrimaries, 9); + assert.strictEqual(meta.cicpTransferCharacteristics, 16); + }); + + it('should preserve pixel values through raw round-trip', async () => { + const { data: orig, info } = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .raw({ depth: 'ushort' }) + .toBuffer({ resolveWithObject: true }); + + assert.strictEqual(info.depth, 'ushort'); + + const ushortBuf = new Uint16Array(orig.buffer, orig.byteOffset, orig.byteLength / 2); + const { data: roundtrip, info: info2 } = await sharp(ushortBuf, { raw: info }) + .keepCicp() + .raw({ depth: 'ushort' }) + .toBuffer({ resolveWithObject: true }); + + assert.strictEqual(info2.depth, 'ushort'); + assert.strictEqual(roundtrip.length, orig.length); + + const obuf = Buffer.from(orig); + const rbuf = Buffer.from(roundtrip); + assertPixelNear( + readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), + readUshortPixel(obuf, 512, PQ.greyBg.x, PQ.greyBg.y), 0, 'grey bg'); + assertPixelNear( + readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), + readUshortPixel(obuf, 512, PQ.red.x, PQ.red.y), 0, 'red patch'); + assertPixelNear( + readUshortPixel(rbuf, 512, PQ.blue.x, PQ.blue.y), + readUshortPixel(obuf, 512, PQ.blue.x, PQ.blue.y), 0, 'blue patch'); + }); + }); + + describe('pipelineColorspace', () => { + it('should strip CICP metadata after linearization', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .pipelineColorspace('scrgb') + .png() + .toBuffer(); + const meta = await sharp(buf).metadata(); + assert.strictEqual(meta.cicpColourPrimaries, undefined); + assert.strictEqual(meta.space, 'srgb'); + }); + + it('should produce clipped SDR from HDR content', async () => { + const { data } = await sharp(fixtures.inputAvifHdr) + .pipelineColorspace('scrgb') + .raw() + .toBuffer({ resolveWithObject: true }); + // CICP2scRGB + sRGB conversion clips without tone mapping. + assertPixelNear(readUcharPixel(data, 512, PQ.greyBg.x, PQ.greyBg.y), [255, 255, 255], 0, 'grey bg'); + assertPixelNear(readUcharPixel(data, 512, PQ.red.x, PQ.red.y), [255, 0, 0], 0, 'red patch'); + assertPixelNear(readUcharPixel(data, 512, PQ.green.x, PQ.green.y), [0, 255, 0], 0, 'green patch'); + assertPixelNear(readUcharPixel(data, 512, PQ.blue.x, PQ.blue.y), [0, 0, 255], 0, 'blue patch'); + }); + + it('should produce wider-gamut colours with P3 profile', async () => { + const { data } = await sharp(fixtures.inputAvifHdr) + .pipelineColorspace('scrgb') + .withIccProfile('p3') + .raw() + .toBuffer({ resolveWithObject: true }); + // P3 has a wider gamut than sRGB, so colour patches retain more detail + const red = readUcharPixel(data, 512, PQ.red.x, PQ.red.y); + const green = readUcharPixel(data, 512, PQ.green.x, PQ.green.y); + const blue = readUcharPixel(data, 512, PQ.blue.x, PQ.blue.y); + assertPixelNear(red, [234, 51, 34], 5, 'red patch in P3'); + assertPixelNear(green, [117, 251, 76], 5, 'green patch in P3'); + assertPixelNear(blue, [0, 0, 245], 5, 'blue patch in P3'); + }); + + it('should produce clipped SDR colours in JXL', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .pipelineColorspace('scrgb') + .jxl({ effort: 3 }) + .toBuffer(); + const { data } = await sharp(buf).raw().toBuffer({ resolveWithObject: true }); + // Same clipping as raw SDR, with minor lossy JXL noise. + assertPixelNear(readUcharPixel(data, 512, PQ.red.x, PQ.red.y), [255, 0, 0], 5, 'red patch'); + assertPixelNear(readUcharPixel(data, 512, PQ.green.x, PQ.green.y), [0, 255, 0], 5, 'green patch'); + assertPixelNear(readUcharPixel(data, 512, PQ.blue.x, PQ.blue.y), [0, 0, 255], 5, 'blue patch'); + }); + }); +}); From 9e8071116aa33e0168b5c402613e70534a1e0aa0 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:36:46 -0400 Subject: [PATCH 4/6] skip icc import --- src/pipeline.cc | 3 ++- test/unit/cicp.js | 20 +++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/pipeline.cc b/src/pipeline.cc index 006bc7690..ea72acdfd 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -353,7 +353,8 @@ class PipelineWorker : public Napi::AsyncWorker { image.interpretation() != VIPS_INTERPRETATION_LABS && image.interpretation() != VIPS_INTERPRETATION_GREY16 && baton->colourspacePipeline != VIPS_INTERPRETATION_CMYK && - !baton->input->ignoreIcc && !baton->withGainMap + !baton->input->ignoreIcc && !baton->withGainMap && + !hasCicp ) { // Convert to sRGB/P3 using embedded profile try { diff --git a/test/unit/cicp.js b/test/unit/cicp.js index d9c8be939..61b021604 100644 --- a/test/unit/cicp.js +++ b/test/unit/cicp.js @@ -118,11 +118,10 @@ describe('CICP handling', () => { .toBuffer(); const { data } = await sharp(buf).raw({ depth: 'ushort' }).toBuffer({ resolveWithObject: true }); const rbuf = Buffer.from(data); - // Default JXL passthrough — lossy noise but close to original PQ values - assertPixelNear(readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), [33337, 33339, 33345], 500, 'grey bg'); - assertPixelNear(readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), [28517, 18293, 12665], 2000, 'red patch'); - assertPixelNear(readUshortPixel(rbuf, 512, PQ.green.x, PQ.green.y), [27560, 32597, 20250], 2000, 'green patch'); - assertPixelNear(readUshortPixel(rbuf, 512, PQ.blue.x, PQ.blue.y), [14676, 10494, 31570], 2000, 'blue patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), PQ.greyBg.rgb, 500, 'grey bg'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), PQ.red.rgb, 2000, 'red patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.green.x, PQ.green.y), PQ.green.rgb, 2000, 'green patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.blue.x, PQ.blue.y), PQ.blue.rgb, 2000, 'blue patch'); }); }); @@ -154,12 +153,11 @@ describe('CICP handling', () => { .toBuffer(); const { data } = await sharp(buf).raw({ depth: 'ushort' }).toBuffer({ resolveWithObject: true }); const rbuf = Buffer.from(data); - // JXL colour management shifts values from the original PQ encoding. - // These reference values are from keepCicp lossy JXL round-trip. - assertPixelNear(readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), [40004, 40006, 40005], 500, 'grey bg'); - assertPixelNear(readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), [40609, 7937, 4010], 500, 'red patch'); - assertPixelNear(readUshortPixel(rbuf, 512, PQ.green.x, PQ.green.y), [18954, 41217, 11503], 500, 'green patch'); - assertPixelNear(readUshortPixel(rbuf, 512, PQ.blue.x, PQ.blue.y), [0, 0, 42025], 500, 'blue patch'); + // With uses_original_profile (XYB bypass), values stay close to originals + assertPixelNear(readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), PQ.greyBg.rgb, 500, 'grey bg'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), PQ.red.rgb, 2000, 'red patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.green.x, PQ.green.y), PQ.green.rgb, 2000, 'green patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.blue.x, PQ.blue.y), PQ.blue.rgb, 2000, 'blue patch'); }); it('should produce UHDR JPEG with gainmap', async () => { From eb11f27e4142fe897bbaf6c76bd24b0a6bc94f90 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:30:26 -0400 Subject: [PATCH 5/6] set bitdepth in test --- test/unit/cicp.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/unit/cicp.js b/test/unit/cicp.js index 61b021604..b716ff9cb 100644 --- a/test/unit/cicp.js +++ b/test/unit/cicp.js @@ -136,14 +136,28 @@ describe('CICP handling', () => { assert.strictEqual(meta.cicpTransferCharacteristics, 16); }); - it('should preserve CICP metadata in AVIF', async () => { + it('should preserve CICP metadata and bitdepth in AVIF', async () => { const buf = await sharp(fixtures.inputAvifHdr) .keepCicp() - .avif({ quality: 80 }) + .avif({ quality: 80, bitdepth: 12 }) .toBuffer(); const meta = await sharp(buf).metadata(); assert.strictEqual(meta.cicpColourPrimaries, 9); assert.strictEqual(meta.cicpTransferCharacteristics, 16); + assert.strictEqual(meta.bitsPerSample, 12); + assert.strictEqual(meta.depth, 'ushort'); + }); + + it('should preserve PQ pixel values in lossless AVIF', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .avif({ lossless: true, bitdepth: 12 }) + .toBuffer(); + const { data } = await sharp(buf).raw({ depth: 'ushort' }).toBuffer({ resolveWithObject: true }); + const rbuf = Buffer.from(data); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), PQ.greyBg.rgb, 0, 'grey bg'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), PQ.red.rgb, 0, 'red patch'); + assertPixelNear(readUshortPixel(rbuf, 512, PQ.redP3text.x, PQ.redP3text.y), PQ.redP3text.rgb, 0, 'P3 text'); }); it('should preserve PQ colour values in lossy JXL', async () => { From c953dc483e103469564ce2e74260d38abb8ec2b0 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:34:02 -0400 Subject: [PATCH 6/6] stale comment --- test/unit/cicp.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/cicp.js b/test/unit/cicp.js index b716ff9cb..81b857335 100644 --- a/test/unit/cicp.js +++ b/test/unit/cicp.js @@ -167,7 +167,6 @@ describe('CICP handling', () => { .toBuffer(); const { data } = await sharp(buf).raw({ depth: 'ushort' }).toBuffer({ resolveWithObject: true }); const rbuf = Buffer.from(data); - // With uses_original_profile (XYB bypass), values stay close to originals assertPixelNear(readUshortPixel(rbuf, 512, PQ.greyBg.x, PQ.greyBg.y), PQ.greyBg.rgb, 500, 'grey bg'); assertPixelNear(readUshortPixel(rbuf, 512, PQ.red.x, PQ.red.y), PQ.red.rgb, 2000, 'red patch'); assertPixelNear(readUshortPixel(rbuf, 512, PQ.green.x, PQ.green.y), PQ.green.rgb, 2000, 'green patch');