diff --git a/docs/api-output.md b/docs/api-output.md index 8d820be7..1092f72b 100644 --- a/docs/api-output.md +++ b/docs/api-output.md @@ -327,6 +327,7 @@ most web browsers do not display these properly. - `options.quality` **[number][9]** quality, integer 1-100 (optional, default `50`) - `options.lossless` **[boolean][7]** use lossless compression (optional, default `false`) - `options.speed` **[boolean][7]** CPU effort vs file size, 0 (slowest/smallest) to 8 (fastest/largest) (optional, default `5`) + - `options.chromaSubsampling` **[string][2]** set to '4:4:4' to prevent chroma subsampling otherwise defaults to '4:2:0' chroma subsampling, requires libvips v8.11.0 (optional, default `'4:2:0'`) - Throws **[Error][4]** Invalid options @@ -351,6 +352,7 @@ globally-installed libvips compiled with support for libheif, libde265 and x265. - `options.compression` **[boolean][7]** compression format: av1, hevc (optional, default `'av1'`) - `options.lossless` **[boolean][7]** use lossless compression (optional, default `false`) - `options.speed` **[boolean][7]** CPU effort vs file size, 0 (slowest/smallest) to 8 (fastest/largest) (optional, default `5`) + - `options.chromaSubsampling` **[string][2]** set to '4:4:4' to prevent chroma subsampling otherwise defaults to '4:2:0' chroma subsampling, requires libvips v8.11.0 (optional, default `'4:2:0'`) - Throws **[Error][4]** Invalid options diff --git a/lib/constructor.js b/lib/constructor.js index 078dcbc3..f17cc061 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -251,6 +251,7 @@ const Sharp = function (input, options) { heifLossless: false, heifCompression: 'av1', heifSpeed: 5, + heifChromaSubsampling: '4:2:0', tileSize: 256, tileOverlap: 0, tileContainer: 'fs', diff --git a/lib/libvips.js b/lib/libvips.js index 4d94457c..87e9bc44 100644 --- a/lib/libvips.js +++ b/lib/libvips.js @@ -39,8 +39,9 @@ const cachePath = function () { const globalLibvipsVersion = function () { if (process.platform !== 'win32') { - const globalLibvipsVersion = spawnSync(`PKG_CONFIG_PATH="${pkgConfigPath()}" pkg-config --modversion vips-cpp`, spawnSyncOptions).stdout || ''; - return globalLibvipsVersion.trim(); + const globalLibvipsVersion = spawnSync(`PKG_CONFIG_PATH="${pkgConfigPath()}" pkg-config --modversion vips-cpp`, spawnSyncOptions).stdout; + /* istanbul ignore next */ + return (globalLibvipsVersion || '').trim(); } else { return ''; } diff --git a/lib/output.js b/lib/output.js index 858c21a6..3a110a37 100644 --- a/lib/output.js +++ b/lib/output.js @@ -583,6 +583,7 @@ function tiff (options) { * @param {number} [options.quality=50] - quality, integer 1-100 * @param {boolean} [options.lossless=false] - use lossless compression * @param {boolean} [options.speed=5] - CPU effort vs file size, 0 (slowest/smallest) to 8 (fastest/largest) + * @param {string} [options.chromaSubsampling='4:2:0'] - set to '4:4:4' to prevent chroma subsampling otherwise defaults to '4:2:0' chroma subsampling, requires libvips v8.11.0 * @returns {Sharp} * @throws {Error} Invalid options */ @@ -603,6 +604,7 @@ function avif (options) { * @param {boolean} [options.compression='av1'] - compression format: av1, hevc * @param {boolean} [options.lossless=false] - use lossless compression * @param {boolean} [options.speed=5] - CPU effort vs file size, 0 (slowest/smallest) to 8 (fastest/largest) + * @param {string} [options.chromaSubsampling='4:2:0'] - set to '4:4:4' to prevent chroma subsampling otherwise defaults to '4:2:0' chroma subsampling, requires libvips v8.11.0 * @returns {Sharp} * @throws {Error} Invalid options */ @@ -636,6 +638,13 @@ function heif (options) { throw is.invalidParameterError('speed', 'integer between 0 and 8', options.speed); } } + if (is.defined(options.chromaSubsampling)) { + if (is.string(options.chromaSubsampling) && is.inArray(options.chromaSubsampling, ['4:2:0', '4:4:4'])) { + this.options.heifChromaSubsampling = options.chromaSubsampling; + } else { + throw is.invalidParameterError('chromaSubsampling', 'one of: 4:2:0, 4:4:4', options.chromaSubsampling); + } + } } return this._updateFormatOut('heif', options); } diff --git a/src/metadata.cc b/src/metadata.cc index 71aa6ec9..be0f2f8f 100644 --- a/src/metadata.cc +++ b/src/metadata.cc @@ -74,6 +74,9 @@ class MetadataWorker : public Napi::AsyncWorker { if (image.get_typeof("heif-primary") == G_TYPE_INT) { baton->pagePrimary = image.get_int("heif-primary"); } + if (image.get_typeof("heif-compression") == VIPS_TYPE_REF_STRING) { + baton->compression = image.get_string("heif-compression"); + } if (image.get_typeof("openslide.level-count") == VIPS_TYPE_REF_STRING) { int const levels = std::stoi(image.get_string("openslide.level-count")); for (int l = 0; l < levels; l++) { @@ -186,6 +189,9 @@ class MetadataWorker : public Napi::AsyncWorker { if (baton->pagePrimary > -1) { info.Set("pagePrimary", baton->pagePrimary); } + if (!baton->compression.empty()) { + info.Set("compression", baton->compression); + } if (!baton->levels.empty()) { int i = 0; Napi::Array levels = Napi::Array::New(env, static_cast(baton->levels.size())); diff --git a/src/metadata.h b/src/metadata.h index 00553085..da9fc80c 100644 --- a/src/metadata.h +++ b/src/metadata.h @@ -39,6 +39,7 @@ struct MetadataBaton { int loop; std::vector delay; int pagePrimary; + std::string compression; std::vector> levels; bool hasProfile; bool hasAlpha; diff --git a/src/pipeline.cc b/src/pipeline.cc index fdb4856b..cf4df382 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -838,6 +838,10 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("compression", baton->heifCompression) ->set("Q", baton->heifQuality) ->set("speed", baton->heifSpeed) +#ifdef VIPS_TYPE_FOREIGN_SUBSAMPLE + ->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4" + ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) +#endif ->set("lossless", baton->heifLossless))); baton->bufferOut = static_cast(area->data); baton->bufferOutLength = area->length; @@ -972,6 +976,10 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("Q", baton->heifQuality) ->set("compression", baton->heifCompression) ->set("speed", baton->heifSpeed) +#ifdef VIPS_TYPE_FOREIGN_SUBSAMPLE + ->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4" + ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) +#endif ->set("lossless", baton->heifLossless)); baton->formatOut = "heif"; } else if (baton->formatOut == "dz" || isDz || isDzZip) { @@ -1396,6 +1404,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { vips_enum_from_nick(nullptr, VIPS_TYPE_FOREIGN_HEIF_COMPRESSION, sharp::AttrAsStr(options, "heifCompression").data())); baton->heifSpeed = sharp::AttrAsUint32(options, "heifSpeed"); + baton->heifChromaSubsampling = sharp::AttrAsStr(options, "heifChromaSubsampling"); // Animated output if (sharp::HasAttr(options, "pageHeight")) { diff --git a/src/pipeline.h b/src/pipeline.h index 135552df..ae6eb73f 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -162,6 +162,7 @@ struct PipelineBaton { int heifQuality; VipsForeignHeifCompression heifCompression; int heifSpeed; + std::string heifChromaSubsampling; bool heifLossless; std::string err; bool withMetadata; @@ -282,6 +283,7 @@ struct PipelineBaton { heifQuality(50), heifCompression(VIPS_FOREIGN_HEIF_COMPRESSION_AV1), heifSpeed(5), + heifChromaSubsampling("4:2:0"), heifLossless(false), withMetadata(false), withMetadataOrientation(-1), diff --git a/test/unit/avif.js b/test/unit/avif.js index 859598d8..780617dd 100644 --- a/test/unit/avif.js +++ b/test/unit/avif.js @@ -18,7 +18,7 @@ describe('AVIF', () => { .toBuffer(); const metadata = await sharp(data) .metadata(); - const { size, ...metadataWithoutSize } = metadata; + const { compression, size, ...metadataWithoutSize } = metadata; assert.deepStrictEqual(metadataWithoutSize, { channels: 3, depth: 'uchar', @@ -42,7 +42,7 @@ describe('AVIF', () => { .toBuffer(); const metadata = await sharp(data) .metadata(); - const { size, ...metadataWithoutSize } = metadata; + const { compression, size, ...metadataWithoutSize } = metadata; assert.deepStrictEqual(metadataWithoutSize, { channels: 3, chromaSubsampling: '4:2:0', @@ -65,7 +65,7 @@ describe('AVIF', () => { .toBuffer(); const metadata = await sharp(data) .metadata(); - const { size, ...metadataWithoutSize } = metadata; + const { compression, size, ...metadataWithoutSize } = metadata; assert.deepStrictEqual(metadataWithoutSize, { channels: 3, depth: 'uchar', diff --git a/test/unit/failOnError.js b/test/unit/failOnError.js index df0147bd..54c1f0b7 100644 --- a/test/unit/failOnError.js +++ b/test/unit/failOnError.js @@ -53,7 +53,7 @@ describe('failOnError', function () { it('returns errors to callback for truncated JPEG', function (done) { sharp(fixtures.inputJpgTruncated).toBuffer(function (err, data, info) { - assert.ok(err.message.includes('VipsJpeg: Premature end of JPEG file'), err); + assert.ok(err.message.includes('VipsJpeg: Premature end of'), err); assert.strictEqual(data, undefined); assert.strictEqual(info, undefined); done(); @@ -76,7 +76,7 @@ describe('failOnError', function () { throw new Error('Expected rejection'); }) .catch(err => { - done(err.message.includes('VipsJpeg: Premature end of JPEG file') ? undefined : err); + done(err.message.includes('VipsJpeg: Premature end of') ? undefined : err); }); }); diff --git a/test/unit/heif.js b/test/unit/heif.js index c67b89b8..3838215f 100644 --- a/test/unit/heif.js +++ b/test/unit/heif.js @@ -65,4 +65,14 @@ describe('HEIF', () => { sharp().heif({ compression: 'fail' }); }); }); + it('invalid chromaSubsampling should throw an error', () => { + assert.throws(() => { + sharp().heif({ chromaSubsampling: 'fail' }); + }); + }); + it('valid chromaSubsampling does not throw an error', () => { + assert.doesNotThrow(() => { + sharp().heif({ chromaSubsampling: '4:4:4' }); + }); + }); }); diff --git a/test/unit/metadata.js b/test/unit/metadata.js index ee94386a..67b7e4f1 100644 --- a/test/unit/metadata.js +++ b/test/unit/metadata.js @@ -667,7 +667,7 @@ describe('Image metadata', function () { sharp(fixtures.inputJpgWithCorruptHeader) .metadata(function (err) { assert.strictEqual(true, !!err); - assert.strictEqual(true, /Input file has corrupt header: VipsJpeg: Premature end of JPEG file/.test(err.message)); + assert.ok(err.message.includes('Input file has corrupt header: VipsJpeg: Premature end of'), err); done(); }); }); @@ -676,7 +676,7 @@ describe('Image metadata', function () { sharp(fs.readFileSync(fixtures.inputJpgWithCorruptHeader)) .metadata(function (err) { assert.strictEqual(true, !!err); - assert.strictEqual(true, /Input buffer has corrupt header: VipsJpeg: Premature end of JPEG file/.test(err.message)); + assert.ok(err.message.includes('Input buffer has corrupt header: VipsJpeg: Premature end of'), err); done(); }); });