diff --git a/docs/src/content/docs/api-output.md b/docs/src/content/docs/api-output.md index b2a25c86..357dada9 100644 --- a/docs/src/content/docs/api-output.md +++ b/docs/src/content/docs/api-output.md @@ -758,6 +758,7 @@ When using Windows ARM64, this feature requires a CPU with ARM64v8.4 or later. | [options.effort] | number | 4 | CPU effort, between 0 (fastest) and 9 (slowest) | | [options.chromaSubsampling] | string | "'4:4:4'" | set to '4:2:0' to use chroma subsampling | | [options.bitdepth] | number | 8 | set bitdepth to 8, 10 or 12 bit | +| [options.tune] | string | "'iq'" | tune output for a quality metric, one of 'iq' (default), 'ssim' or 'psnr' | **Example** ```js @@ -797,6 +798,7 @@ globally-installed libvips compiled with support for libheif, libde265 and x265. | [options.effort] | number | 4 | CPU effort, between 0 (fastest) and 9 (slowest) | | [options.chromaSubsampling] | string | "'4:4:4'" | set to '4:2:0' to use chroma subsampling | | [options.bitdepth] | number | 8 | set bitdepth to 8, 10 or 12 bit | +| [options.tune] | string | "'ssim'" | tune output for a quality metric, one of 'ssim' (default), 'psnr' or 'iq' | **Example** ```js diff --git a/docs/src/content/docs/changelog/v0.35.0.md b/docs/src/content/docs/changelog/v0.35.0.md index 84f78a0a..28cbc2ce 100644 --- a/docs/src/content/docs/changelog/v0.35.0.md +++ b/docs/src/content/docs/changelog/v0.35.0.md @@ -8,6 +8,8 @@ slug: changelog/v0.35.0 * Breaking: Remove `install` script from `package.json` file. Compiling from source is now opt-in via the `build` script. +* Breaking: AVIF output is now tuned using SSIMULACRA2-based `iq` quality metrics rather than `ssim`. + * Breaking: Remove deprecated `failOnError` constructor property. * Breaking: Remove deprecated `paletteBitDepth` from `metadata` response. @@ -18,6 +20,9 @@ slug: changelog/v0.35.0 * Deprecate Windows 32-bit (win32-ia32) prebuilt binaries. +* Add AVIF/HEIF `tune` option for control over quality metrics. + [#4227](https://github.com/lovell/sharp/issues/4227) + * Add `withGainMap` to process HDR JPEG images with embedded gain maps. [#4314](https://github.com/lovell/sharp/issues/4314) diff --git a/lib/constructor.js b/lib/constructor.js index ae22dff0..99267ae6 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -378,6 +378,7 @@ const Sharp = function (input, options) { heifEffort: 4, heifChromaSubsampling: '4:4:4', heifBitdepth: 8, + heifTune: 'ssim', jxlDistance: 1, jxlDecodingTier: 0, jxlEffort: 7, diff --git a/lib/index.d.ts b/lib/index.d.ts index 7c9fa0c5..9f35cc40 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1167,6 +1167,8 @@ declare namespace sharp { type HeifCompression = 'av1' | 'hevc'; + type HeifTune = 'iq' | 'ssim' | 'psnr'; + type Unit = 'inch' | 'cm'; interface WriteableMetadata { @@ -1414,6 +1416,8 @@ declare namespace sharp { chromaSubsampling?: string | undefined; /** Set bitdepth to 8, 10 or 12 bit (optional, default 8) */ bitdepth?: 8 | 10 | 12 | undefined; + /** Tune output for a quality metric, one of 'iq', 'ssim' or 'psnr' (optional, default 'iq') */ + tune?: HeifTune | undefined; } interface HeifOptions extends OutputOptions { @@ -1429,6 +1433,8 @@ declare namespace sharp { chromaSubsampling?: string | undefined; /** Set bitdepth to 8, 10 or 12 bit (optional, default 8) */ bitdepth?: 8 | 10 | 12 | undefined; + /** Tune output for a quality metric, one of 'ssim', 'psnr' or 'iq' (optional, default 'ssim') */ + tune?: HeifTune | undefined; } interface GifOptions extends OutputOptions, AnimationOptions { diff --git a/lib/output.js b/lib/output.js index fbd09bfb..9c26b471 100644 --- a/lib/output.js +++ b/lib/output.js @@ -1175,11 +1175,13 @@ function tiff (options) { * @param {number} [options.effort=4] - CPU effort, between 0 (fastest) and 9 (slowest) * @param {string} [options.chromaSubsampling='4:4:4'] - set to '4:2:0' to use chroma subsampling * @param {number} [options.bitdepth=8] - set bitdepth to 8, 10 or 12 bit + * @param {string} [options.tune='iq'] - tune output for a quality metric, one of 'iq' (default), 'ssim' or 'psnr' * @returns {Sharp} * @throws {Error} Invalid options */ function avif (options) { - return this.heif({ ...options, compression: 'av1' }); + const tune = is.object(options) && is.defined(options.tune) ? options.tune : 'iq'; + return this.heif({ ...options, compression: 'av1', tune }); } /** @@ -1202,6 +1204,7 @@ function avif (options) { * @param {number} [options.effort=4] - CPU effort, between 0 (fastest) and 9 (slowest) * @param {string} [options.chromaSubsampling='4:4:4'] - set to '4:2:0' to use chroma subsampling * @param {number} [options.bitdepth=8] - set bitdepth to 8, 10 or 12 bit + * @param {string} [options.tune='ssim'] - tune output for a quality metric, one of 'ssim' (default), 'psnr' or 'iq' * @returns {Sharp} * @throws {Error} Invalid options */ @@ -1250,6 +1253,13 @@ function heif (options) { throw is.invalidParameterError('bitdepth', '8, 10 or 12', options.bitdepth); } } + if (is.defined(options.tune)) { + if (is.string(options.tune) && is.inArray(options.tune, ['iq', 'ssim', 'psnr'])) { + this.options.heifTune = options.tune; + } else { + throw is.invalidParameterError('tune', 'one of: psnr, ssim, iq', options.tune); + } + } } else { throw is.invalidParameterError('options', 'Object', options); } diff --git a/src/pipeline.cc b/src/pipeline.cc index 891494df..3ede4a35 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -1033,6 +1033,7 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("compression", baton->heifCompression) ->set("effort", baton->heifEffort) ->set("bitdepth", baton->heifBitdepth) + ->set("tune", baton->heifTune.c_str()) ->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4" ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) ->set("lossless", baton->heifLossless))); @@ -1233,6 +1234,7 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("compression", baton->heifCompression) ->set("effort", baton->heifEffort) ->set("bitdepth", baton->heifBitdepth) + ->set("tune", baton->heifTune.c_str()) ->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4" ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) ->set("lossless", baton->heifLossless)); @@ -1798,6 +1800,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->heifEffort = sharp::AttrAsUint32(options, "heifEffort"); baton->heifChromaSubsampling = sharp::AttrAsStr(options, "heifChromaSubsampling"); baton->heifBitdepth = sharp::AttrAsUint32(options, "heifBitdepth"); + baton->heifTune = sharp::AttrAsStr(options, "heifTune"); baton->jxlDistance = sharp::AttrAsDouble(options, "jxlDistance"); baton->jxlDecodingTier = sharp::AttrAsUint32(options, "jxlDecodingTier"); baton->jxlEffort = sharp::AttrAsUint32(options, "jxlEffort"); diff --git a/src/pipeline.h b/src/pipeline.h index 16464d3c..718faaf3 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -196,6 +196,7 @@ struct PipelineBaton { std::string heifChromaSubsampling; bool heifLossless; int heifBitdepth; + std::string heifTune; double jxlDistance; int jxlDecodingTier; int jxlEffort; @@ -376,6 +377,7 @@ struct PipelineBaton { heifChromaSubsampling("4:4:4"), heifLossless(false), heifBitdepth(8), + heifTune("ssim"), jxlDistance(1.0), jxlDecodingTier(0), jxlEffort(7), diff --git a/test/types/sharp.test-d.ts b/test/types/sharp.test-d.ts index 22165af3..ba831b6c 100644 --- a/test/types/sharp.test-d.ts +++ b/test/types/sharp.test-d.ts @@ -365,7 +365,7 @@ sharp(input) .avif({ quality: 50, lossless: false, effort: 5, chromaSubsampling: '4:2:0' }) .heif() .heif({}) - .heif({ quality: 50, compression: 'hevc', lossless: false, effort: 5, chromaSubsampling: '4:2:0' }) + .heif({ quality: 50, compression: 'hevc', lossless: false, effort: 5, chromaSubsampling: '4:2:0', tune: 'psnr' }) .toBuffer({ resolveWithObject: true }) .then(({ data, info }) => { console.log(data); diff --git a/test/unit/avif.js b/test/unit/avif.js index 6fb08c6f..0a47b4e0 100644 --- a/test/unit/avif.js +++ b/test/unit/avif.js @@ -181,4 +181,16 @@ describe('AVIF', () => { /Expected 8, 10 or 12 for bitdepth but received 11 of type number/ ) ); + + it('Different tune options result in different file sizes', async () => { + const ssim = await sharp(inputJpg) + .resize(32) + .avif({ tune: 'ssim', effort: 0 }) + .toBuffer(); + const iq = await sharp(inputJpg) + .resize(32) + .avif({ tune: 'iq', effort: 0 }) + .toBuffer(); + assert(ssim.length < iq.length); + }) }); diff --git a/test/unit/heif.js b/test/unit/heif.js index adb7b296..025457b1 100644 --- a/test/unit/heif.js +++ b/test/unit/heif.js @@ -96,4 +96,14 @@ describe('HEIF', () => { sharp().heif({ compression: 'av1', bitdepth: 11 }); }, /Error: Expected 8, 10 or 12 for bitdepth but received 11 of type number/); }); + it('valid tune does not throw an error', () => { + assert.doesNotThrow(() => { + sharp().heif({ compression: 'hevc', tune: 'psnr' }); + }); + }); + it('invalid tune should throw an error', () => { + assert.throws(() => { + sharp().heif({ compression: 'hevc', tune: 'fail' }); + }, /Error: Expected one of: psnr, ssim, iq for tune but received fail of type string/); + }); });