Add AVIF/HEIF 'tune' option to control quality metrics #4227

This commit is contained in:
Lovell Fuller
2026-01-01 22:41:18 +00:00
parent 0d872bd13a
commit 006d37b2d0
10 changed files with 53 additions and 2 deletions

View File

@@ -758,6 +758,7 @@ When using Windows ARM64, this feature requires a CPU with ARM64v8.4 or later.
| [options.effort] | <code>number</code> | <code>4</code> | CPU effort, between 0 (fastest) and 9 (slowest) | | [options.effort] | <code>number</code> | <code>4</code> | CPU effort, between 0 (fastest) and 9 (slowest) |
| [options.chromaSubsampling] | <code>string</code> | <code>&quot;&#x27;4:4:4&#x27;&quot;</code> | set to '4:2:0' to use chroma subsampling | | [options.chromaSubsampling] | <code>string</code> | <code>&quot;&#x27;4:4:4&#x27;&quot;</code> | set to '4:2:0' to use chroma subsampling |
| [options.bitdepth] | <code>number</code> | <code>8</code> | set bitdepth to 8, 10 or 12 bit | | [options.bitdepth] | <code>number</code> | <code>8</code> | set bitdepth to 8, 10 or 12 bit |
| [options.tune] | <code>string</code> | <code>&quot;&#x27;iq&#x27;&quot;</code> | tune output for a quality metric, one of 'iq' (default), 'ssim' or 'psnr' |
**Example** **Example**
```js ```js
@@ -797,6 +798,7 @@ globally-installed libvips compiled with support for libheif, libde265 and x265.
| [options.effort] | <code>number</code> | <code>4</code> | CPU effort, between 0 (fastest) and 9 (slowest) | | [options.effort] | <code>number</code> | <code>4</code> | CPU effort, between 0 (fastest) and 9 (slowest) |
| [options.chromaSubsampling] | <code>string</code> | <code>&quot;&#x27;4:4:4&#x27;&quot;</code> | set to '4:2:0' to use chroma subsampling | | [options.chromaSubsampling] | <code>string</code> | <code>&quot;&#x27;4:4:4&#x27;&quot;</code> | set to '4:2:0' to use chroma subsampling |
| [options.bitdepth] | <code>number</code> | <code>8</code> | set bitdepth to 8, 10 or 12 bit | | [options.bitdepth] | <code>number</code> | <code>8</code> | set bitdepth to 8, 10 or 12 bit |
| [options.tune] | <code>string</code> | <code>&quot;&#x27;ssim&#x27;&quot;</code> | tune output for a quality metric, one of 'ssim' (default), 'psnr' or 'iq' |
**Example** **Example**
```js ```js

View File

@@ -8,6 +8,8 @@ slug: changelog/v0.35.0
* Breaking: Remove `install` script from `package.json` file. * Breaking: Remove `install` script from `package.json` file.
Compiling from source is now opt-in via the `build` script. 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 `failOnError` constructor property.
* Breaking: Remove deprecated `paletteBitDepth` from `metadata` response. * Breaking: Remove deprecated `paletteBitDepth` from `metadata` response.
@@ -18,6 +20,9 @@ slug: changelog/v0.35.0
* Deprecate Windows 32-bit (win32-ia32) prebuilt binaries. * 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. * Add `withGainMap` to process HDR JPEG images with embedded gain maps.
[#4314](https://github.com/lovell/sharp/issues/4314) [#4314](https://github.com/lovell/sharp/issues/4314)

View File

@@ -378,6 +378,7 @@ const Sharp = function (input, options) {
heifEffort: 4, heifEffort: 4,
heifChromaSubsampling: '4:4:4', heifChromaSubsampling: '4:4:4',
heifBitdepth: 8, heifBitdepth: 8,
heifTune: 'ssim',
jxlDistance: 1, jxlDistance: 1,
jxlDecodingTier: 0, jxlDecodingTier: 0,
jxlEffort: 7, jxlEffort: 7,

6
lib/index.d.ts vendored
View File

@@ -1167,6 +1167,8 @@ declare namespace sharp {
type HeifCompression = 'av1' | 'hevc'; type HeifCompression = 'av1' | 'hevc';
type HeifTune = 'iq' | 'ssim' | 'psnr';
type Unit = 'inch' | 'cm'; type Unit = 'inch' | 'cm';
interface WriteableMetadata { interface WriteableMetadata {
@@ -1414,6 +1416,8 @@ declare namespace sharp {
chromaSubsampling?: string | undefined; chromaSubsampling?: string | undefined;
/** Set bitdepth to 8, 10 or 12 bit (optional, default 8) */ /** Set bitdepth to 8, 10 or 12 bit (optional, default 8) */
bitdepth?: 8 | 10 | 12 | undefined; 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 { interface HeifOptions extends OutputOptions {
@@ -1429,6 +1433,8 @@ declare namespace sharp {
chromaSubsampling?: string | undefined; chromaSubsampling?: string | undefined;
/** Set bitdepth to 8, 10 or 12 bit (optional, default 8) */ /** Set bitdepth to 8, 10 or 12 bit (optional, default 8) */
bitdepth?: 8 | 10 | 12 | undefined; 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 { interface GifOptions extends OutputOptions, AnimationOptions {

View File

@@ -1175,11 +1175,13 @@ function tiff (options) {
* @param {number} [options.effort=4] - CPU effort, between 0 (fastest) and 9 (slowest) * @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 {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 {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} * @returns {Sharp}
* @throws {Error} Invalid options * @throws {Error} Invalid options
*/ */
function avif (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 {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 {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 {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} * @returns {Sharp}
* @throws {Error} Invalid options * @throws {Error} Invalid options
*/ */
@@ -1250,6 +1253,13 @@ function heif (options) {
throw is.invalidParameterError('bitdepth', '8, 10 or 12', options.bitdepth); 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 { } else {
throw is.invalidParameterError('options', 'Object', options); throw is.invalidParameterError('options', 'Object', options);
} }

View File

@@ -1033,6 +1033,7 @@ class PipelineWorker : public Napi::AsyncWorker {
->set("compression", baton->heifCompression) ->set("compression", baton->heifCompression)
->set("effort", baton->heifEffort) ->set("effort", baton->heifEffort)
->set("bitdepth", baton->heifBitdepth) ->set("bitdepth", baton->heifBitdepth)
->set("tune", baton->heifTune.c_str())
->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4" ->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4"
? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON)
->set("lossless", baton->heifLossless))); ->set("lossless", baton->heifLossless)));
@@ -1233,6 +1234,7 @@ class PipelineWorker : public Napi::AsyncWorker {
->set("compression", baton->heifCompression) ->set("compression", baton->heifCompression)
->set("effort", baton->heifEffort) ->set("effort", baton->heifEffort)
->set("bitdepth", baton->heifBitdepth) ->set("bitdepth", baton->heifBitdepth)
->set("tune", baton->heifTune.c_str())
->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4" ->set("subsample_mode", baton->heifChromaSubsampling == "4:4:4"
? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON)
->set("lossless", baton->heifLossless)); ->set("lossless", baton->heifLossless));
@@ -1798,6 +1800,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
baton->heifEffort = sharp::AttrAsUint32(options, "heifEffort"); baton->heifEffort = sharp::AttrAsUint32(options, "heifEffort");
baton->heifChromaSubsampling = sharp::AttrAsStr(options, "heifChromaSubsampling"); baton->heifChromaSubsampling = sharp::AttrAsStr(options, "heifChromaSubsampling");
baton->heifBitdepth = sharp::AttrAsUint32(options, "heifBitdepth"); baton->heifBitdepth = sharp::AttrAsUint32(options, "heifBitdepth");
baton->heifTune = sharp::AttrAsStr(options, "heifTune");
baton->jxlDistance = sharp::AttrAsDouble(options, "jxlDistance"); baton->jxlDistance = sharp::AttrAsDouble(options, "jxlDistance");
baton->jxlDecodingTier = sharp::AttrAsUint32(options, "jxlDecodingTier"); baton->jxlDecodingTier = sharp::AttrAsUint32(options, "jxlDecodingTier");
baton->jxlEffort = sharp::AttrAsUint32(options, "jxlEffort"); baton->jxlEffort = sharp::AttrAsUint32(options, "jxlEffort");

View File

@@ -196,6 +196,7 @@ struct PipelineBaton {
std::string heifChromaSubsampling; std::string heifChromaSubsampling;
bool heifLossless; bool heifLossless;
int heifBitdepth; int heifBitdepth;
std::string heifTune;
double jxlDistance; double jxlDistance;
int jxlDecodingTier; int jxlDecodingTier;
int jxlEffort; int jxlEffort;
@@ -376,6 +377,7 @@ struct PipelineBaton {
heifChromaSubsampling("4:4:4"), heifChromaSubsampling("4:4:4"),
heifLossless(false), heifLossless(false),
heifBitdepth(8), heifBitdepth(8),
heifTune("ssim"),
jxlDistance(1.0), jxlDistance(1.0),
jxlDecodingTier(0), jxlDecodingTier(0),
jxlEffort(7), jxlEffort(7),

View File

@@ -365,7 +365,7 @@ sharp(input)
.avif({ quality: 50, lossless: false, effort: 5, chromaSubsampling: '4:2:0' }) .avif({ quality: 50, lossless: false, effort: 5, chromaSubsampling: '4:2:0' })
.heif() .heif()
.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 }) .toBuffer({ resolveWithObject: true })
.then(({ data, info }) => { .then(({ data, info }) => {
console.log(data); console.log(data);

View File

@@ -181,4 +181,16 @@ describe('AVIF', () => {
/Expected 8, 10 or 12 for bitdepth but received 11 of type number/ /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);
})
}); });

View File

@@ -96,4 +96,14 @@ describe('HEIF', () => {
sharp().heif({ compression: 'av1', bitdepth: 11 }); sharp().heif({ compression: 'av1', bitdepth: 11 });
}, /Error: Expected 8, 10 or 12 for bitdepth but received 11 of type number/); }, /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/);
});
}); });