diff --git a/docs/src/content/docs/changelog/v0.35.0.md b/docs/src/content/docs/changelog/v0.35.0.md index 6e92ba0d..9f97d0e3 100644 --- a/docs/src/content/docs/changelog/v0.35.0.md +++ b/docs/src/content/docs/changelog/v0.35.0.md @@ -38,4 +38,7 @@ slug: changelog/v0.35.0 [#4480](https://github.com/lovell/sharp/issues/4480) [@eddienubes](https://github.com/eddienubes) +* Ensure HEIF primary item is used as default page/frame. + [#4487](https://github.com/lovell/sharp/issues/4487) + * Add WebP `exact` option for control over transparent pixel colour values. diff --git a/src/common.cc b/src/common.cc index 22d74265..882e22da 100644 --- a/src/common.cc +++ b/src/common.cc @@ -426,7 +426,7 @@ namespace sharp { } if (ImageTypeSupportsPage(imageType)) { option->set("n", descriptor->pages); - option->set("page", descriptor->page); + option->set("page", std::max(0, descriptor->page)); } switch (imageType) { case ImageType::SVG: @@ -456,6 +456,22 @@ namespace sharp { return option; } + /* + Should HEIF image be re-opened using the primary item? + */ + static bool HeifPrimaryPageReopen(VImage image, InputDescriptor *descriptor, vips::VOption *option) { + if (image.get_typeof(VIPS_META_N_PAGES) == G_TYPE_INT && image.get_typeof("heif-primary") == G_TYPE_INT) { + if (image.get_int(VIPS_META_N_PAGES) > 1 && descriptor->pages == 1 && descriptor->page == -1) { + int const pagePrimary = image.get_int("heif-primary"); + if (pagePrimary != 0) { + descriptor->page = pagePrimary; + return true; + } + } + } + return false; + } + /* Open an image from the given InputDescriptor (filesystem, compressed buffer, raw pixel data) */ @@ -490,6 +506,9 @@ namespace sharp { image = VImage::new_from_buffer(descriptor->buffer, descriptor->bufferLength, nullptr, option); if (imageType == ImageType::SVG || imageType == ImageType::PDF || imageType == ImageType::MAGICK) { image = SetDensity(image, descriptor->density); + } else if (imageType == ImageType::HEIF && HeifPrimaryPageReopen(image, descriptor, option)) { + option = GetOptionsForImageType(imageType, descriptor); + image = VImage::new_from_buffer(descriptor->buffer, descriptor->bufferLength, nullptr, option); } } catch (vips::VError const &err) { throw vips::VError(std::string("Input buffer has corrupt header: ") + err.what()); @@ -577,6 +596,9 @@ namespace sharp { image = VImage::new_from_file(descriptor->file.data(), option); if (imageType == ImageType::SVG || imageType == ImageType::PDF || imageType == ImageType::MAGICK) { image = SetDensity(image, descriptor->density); + } else if (imageType == ImageType::HEIF && HeifPrimaryPageReopen(image, descriptor, option)) { + option = GetOptionsForImageType(imageType, descriptor); + image = VImage::new_from_file(descriptor->file.data(), option); } } catch (vips::VError const &err) { throw vips::VError(std::string("Input file has corrupt header: ") + err.what()); diff --git a/src/common.h b/src/common.h index 01fee215..e43856b5 100644 --- a/src/common.h +++ b/src/common.h @@ -105,7 +105,7 @@ namespace sharp { rawPremultiplied(false), rawPageHeight(0), pages(1), - page(0), + page(-1), createChannels(0), createWidth(0), createHeight(0), diff --git a/src/pipeline.cc b/src/pipeline.cc index c45279a2..2de6d43e 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -84,7 +84,7 @@ class PipelineWorker : public Napi::AsyncWorker { if (nPages == -1) { // Resolve the number of pages if we need to render until the end of the document nPages = image.get_typeof(VIPS_META_N_PAGES) != 0 - ? image.get_int(VIPS_META_N_PAGES) - baton->input->page + ? image.get_int(VIPS_META_N_PAGES) - std::max(0, baton->input->page) : 1; } diff --git a/test/fixtures/index.js b/test/fixtures/index.js index 3cd588dd..cb7fce53 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -127,7 +127,7 @@ module.exports = { inputSvgSmallViewBox: getPath('circle.svg'), 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 inputJPGBig: getPath('flowers.jpeg'), inputPngDotAndLines: getPath('dot-and-lines.png'), diff --git a/test/fixtures/pitm.avif b/test/fixtures/pitm.avif new file mode 100644 index 00000000..06dd34ac Binary files /dev/null and b/test/fixtures/pitm.avif differ diff --git a/test/unit/avif.js b/test/unit/avif.js index aa08b438..851d6852 100644 --- a/test/unit/avif.js +++ b/test/unit/avif.js @@ -7,7 +7,13 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const sharp = require('../../'); -const { inputAvif, inputJpg, inputGifAnimated, inputPng } = require('../fixtures'); +const { + inputAvif, + inputAvifWithPitmBox, + inputJpg, + inputGifAnimated, + inputPng, +} = require('../fixtures'); describe('AVIF', () => { it('called without options does not throw an error', () => { @@ -17,16 +23,13 @@ describe('AVIF', () => { }); it('can convert AVIF to JPEG', async () => { - const data = await sharp(inputAvif) - .resize(32) - .jpeg() - .toBuffer(); + const data = await sharp(inputAvif).resize(32).jpeg().toBuffer(); const { size, ...metadata } = await sharp(data).metadata(); void size; assert.deepStrictEqual(metadata, { autoOrient: { height: 13, - width: 32 + width: 32, }, channels: 3, chromaSubsampling: '4:2:0', @@ -41,7 +44,7 @@ describe('AVIF', () => { isProgressive: false, isPalette: false, space: 'srgb', - width: 32 + width: 32, }); }); @@ -55,7 +58,7 @@ describe('AVIF', () => { assert.deepStrictEqual(metadata, { autoOrient: { height: 26, - width: 32 + width: 32, }, channels: 3, compression: 'av1', @@ -70,7 +73,7 @@ describe('AVIF', () => { pagePrimary: 0, pages: 1, space: 'srgb', - width: 32 + width: 32, }); }); @@ -84,7 +87,7 @@ describe('AVIF', () => { assert.deepStrictEqual(metadata, { autoOrient: { height: 24, - width: 32 + width: 32, }, channels: 3, compression: 'av1', @@ -99,20 +102,18 @@ describe('AVIF', () => { pagePrimary: 0, pages: 1, space: 'srgb', - width: 32 + width: 32, }); }); it('can passthrough AVIF', async () => { - const data = await sharp(inputAvif) - .resize(32) - .toBuffer(); + const data = await sharp(inputAvif).resize(32).toBuffer(); const { size, ...metadata } = await sharp(data).metadata(); void size; assert.deepStrictEqual(metadata, { autoOrient: { height: 13, - width: 32 + width: 32, }, channels: 3, compression: 'av1', @@ -127,7 +128,7 @@ describe('AVIF', () => { pagePrimary: 0, pages: 1, space: 'srgb', - width: 32 + width: 32, }); }); @@ -141,7 +142,7 @@ describe('AVIF', () => { assert.deepStrictEqual(metadata, { autoOrient: { height: 300, - width: 10 + width: 10, }, channels: 4, compression: 'av1', @@ -156,7 +157,7 @@ describe('AVIF', () => { pagePrimary: 0, pages: 1, space: 'srgb', - width: 10 + width: 10, }); }); @@ -171,7 +172,7 @@ describe('AVIF', () => { assert.deepStrictEqual(metadata, { autoOrient: { height: 26, - width: 32 + width: 32, }, channels: 3, compression: 'av1', @@ -186,30 +187,37 @@ describe('AVIF', () => { pagePrimary: 0, pages: 1, space: 'srgb', - width: 32 + width: 32, }); }); it('Invalid width - too large', async () => assert.rejects( - () => sharp({ create: { width: 16385, height: 16, channels: 3, background: 'red' } }).avif().toBuffer(), - /Processed image is too large for the HEIF format/ - ) - ); + () => + sharp({ + create: { width: 16385, height: 16, channels: 3, background: 'red' }, + }) + .avif() + .toBuffer(), + /Processed image is too large for the HEIF format/, + )); it('Invalid height - too large', async () => assert.rejects( - () => sharp({ create: { width: 16, height: 16385, channels: 3, background: 'red' } }).avif().toBuffer(), - /Processed image is too large for the HEIF format/ - ) - ); + () => + sharp({ + create: { width: 16, height: 16385, channels: 3, background: 'red' }, + }) + .avif() + .toBuffer(), + /Processed image is too large for the HEIF format/, + )); it('Invalid bitdepth value throws error', () => assert.throws( () => sharp().avif({ bitdepth: 11 }), - /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) @@ -221,5 +229,47 @@ describe('AVIF', () => { .avif({ tune: 'iq', effort: 0 }) .toBuffer(); assert(ssim.length < iq.length); - }) + }); + + it('AVIF with non-zero primary item uses it as default page', async () => { + const { exif, ...metadata } = await sharp(inputAvifWithPitmBox).metadata(); + void exif; + assert.deepStrictEqual(metadata, { + format: 'heif', + width: 4096, + height: 800, + space: 'srgb', + channels: 3, + depth: 'uchar', + isProgressive: false, + isPalette: false, + bitsPerSample: 8, + pages: 5, + pagePrimary: 4, + compression: 'av1', + resolutionUnit: 'cm', + hasProfile: false, + hasAlpha: false, + autoOrient: { width: 4096, height: 800 }, + }); + + const data = await sharp(inputAvifWithPitmBox) + .png({ compressionLevel: 0 }) + .toBuffer(); + const { size, ...pngMetadata } = await sharp(data).metadata(); + assert.deepStrictEqual(pngMetadata, { + format: 'png', + width: 4096, + height: 800, + space: 'srgb', + channels: 3, + depth: 'uchar', + isProgressive: false, + isPalette: false, + bitsPerSample: 8, + hasProfile: false, + hasAlpha: false, + autoOrient: { width: 4096, height: 800 }, + }); + }); });