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..ea72acdfd 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) { @@ -343,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 { @@ -804,7 +815,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); @@ -831,8 +843,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) @@ -1083,6 +1095,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 +1364,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/fixtures/hdr-pq-bt2020.avif b/test/fixtures/hdr-pq-bt2020.avif new file mode 100644 index 000000000..249eee7b2 Binary files /dev/null and b/test/fixtures/hdr-pq-bt2020.avif differ diff --git a/test/fixtures/index.js b/test/fixtures/index.js index cb7fce53a..b24ad8f86 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -128,6 +128,7 @@ module.exports = { inputSvgWithEmbeddedImages: getPath('struct-image-04-t.svg'), // https://dev.w3.org/SVG/profiles/1.2T/test/svg/struct-image-04-t.svg inputAvif: getPath('sdr_cosmos12920_cicp1-13-6_yuv444_full_qp10.avif'), // CC by-nc-nd https://github.com/AOMediaCodec/av1-avif/tree/master/testFiles/Netflix inputAvifWithPitmBox: getPath('pitm.avif'), // https://github.com/lovell/sharp/issues/4487 + inputAvifHdr: getPath('hdr-pq-bt2020.avif'), inputJPGBig: getPath('flowers.jpeg'), inputPngDotAndLines: getPath('dot-and-lines.png'), diff --git a/test/unit/cicp.js b/test/unit/cicp.js new file mode 100644 index 000000000..81b857335 --- /dev/null +++ b/test/unit/cicp.js @@ -0,0 +1,321 @@ +/*! + Copyright 2013 Lovell Fuller and others. + SPDX-License-Identifier: Apache-2.0 +*/ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const sharp = require('../../'); +const fixtures = require('../fixtures'); + +// Reference PQ ushort values from hdr-pq-bt2020.avif (BT.2020 PQ, 12-bit) +const PQ = { + greyBg: { x: 256, y: 10, rgb: [33296, 33296, 33296] }, + whiteBox: { x: 256, y: 80, rgb: [48112, 48112, 48112] }, + text100nit: { x: 170, y: 270, rgb: [31840, 31840, 31840] }, + red: { x: 85, y: 430, rgb: [30272, 17776, 11568] }, + redP3text: { x: 60, y: 415, rgb: [31232, 16272, 5744] }, + green: { x: 256, y: 430, rgb: [26288, 32736, 18960] }, + blue: { x: 427, y: 430, rgb: [15728, 10368, 32768] } +}; + +function readUshortPixel (buf, width, x, y) { + const off = (y * width + x) * 6; + return [buf.readUInt16LE(off), buf.readUInt16LE(off + 2), buf.readUInt16LE(off + 4)]; +} + +function readUcharPixel (buf, width, x, y) { + const off = (y * width + x) * 3; + return [buf[off], buf[off + 1], buf[off + 2]]; +} + +function assertPixelNear (actual, expected, tolerance, label) { + for (let i = 0; i < 3; i++) { + assert.ok(Math.abs(actual[i] - expected[i]) <= tolerance, + `${label} channel ${i}: got ${actual[i]}, expected ${expected[i]} ±${tolerance}`); + } +} + +describe('CICP handling', () => { + 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); + 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'); + }); + }); + + 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 and bitdepth in AVIF', async () => { + const buf = await sharp(fixtures.inputAvifHdr) + .keepCicp() + .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 () => { + 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); + 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 () => { + 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'); + }); + }); +}); 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