diff --git a/src/common.cc b/src/common.cc index b8978772b..b826f42fd 100644 --- a/src/common.cc +++ b/src/common.cc @@ -399,6 +399,7 @@ namespace sharp { imageType == ImageType::JP2 || imageType == ImageType::TIFF || imageType == ImageType::HEIF || + imageType == ImageType::JXL || imageType == ImageType::PDF; } diff --git a/src/pipeline.cc b/src/pipeline.cc index c3b72b03c..49c0fb389 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -1205,7 +1205,6 @@ class PipelineWorker : public Napi::AsyncWorker { } else if (baton->formatOut == "jxl" || (baton->formatOut == "input" && inputImageType == sharp::ImageType::JXL)) { // Write JXL to buffer - image = sharp::RemoveAnimationProperties(image); VipsArea *area = reinterpret_cast(image.jxlsave_buffer(VImage::option() ->set("keep", baton->keepMetadata) ->set("distance", baton->jxlDistance) @@ -1397,7 +1396,6 @@ class PipelineWorker : public Napi::AsyncWorker { } else if (baton->formatOut == "jxl" || (mightMatchInput && isJxl) || (willMatchInput && inputImageType == sharp::ImageType::JXL)) { // Write JXL to file - image = sharp::RemoveAnimationProperties(image); image.jxlsave(const_cast(baton->fileOut.data()), VImage::option() ->set("keep", baton->keepMetadata) ->set("distance", baton->jxlDistance) diff --git a/test/fixtures/index.js b/test/fixtures/index.js index f3b78c5ed..3e5f59322 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -125,6 +125,7 @@ module.exports = { inputGifGreyPlusAlpha: getPath('grey-plus-alpha.gif'), // http://i.imgur.com/gZ5jlmE.gif inputGifAnimated: getPath('rotating-squares.gif'), // CC0 https://loading.io/spinner/blocks/-rotating-squares-preloader-gif inputGifAnimatedLoop3: getPath('animated-loop-3.gif'), // CC-BY-SA-4.0 Petrus3743 https://commons.wikimedia.org/wiki/File:01-Goldener_Schnitt_Formel-Animation.gif + inputJxlAnimated: getPath('rotating-squares.jxl'), // CC0 https://loading.io/spinner/blocks/-rotating-squares-preloader-gif inputSvg: getPath('check.svg'), // http://dev.w3.org/SVG/tools/svgweb/samples/svg-files/check.svg 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 diff --git a/test/fixtures/rotating-squares.jxl b/test/fixtures/rotating-squares.jxl new file mode 100644 index 000000000..b305dfbd2 Binary files /dev/null and b/test/fixtures/rotating-squares.jxl differ diff --git a/test/unit/jxl.js b/test/unit/jxl.js index 643267d04..47df4fc5a 100644 --- a/test/unit/jxl.js +++ b/test/unit/jxl.js @@ -6,6 +6,7 @@ const { suite, test } = require('node:test'); const sharp = require('../../'); +const fixtures = require('../fixtures'); suite('JXL', () => { test('called without options does not throw an error', (t) => { @@ -114,4 +115,93 @@ suite('JXL', () => { sharp().jxl({ effort: 'fail' }); }); }); + test('invalid loop throws', (t) => { + t.plan(2); + t.assert.throws(() => { + sharp().jxl({ loop: -1 }); + }); + t.assert.throws(() => { + sharp().jxl({ loop: 65536 }); + }); + }); + test('invalid delay throws', (t) => { + t.plan(2); + t.assert.throws(() => { + sharp().jxl({ delay: -1 }); + }); + t.assert.throws(() => { + sharp().jxl({ delay: [65536] }); + }); + }); + + suite('animated', { skip: !sharp.format.jxl.input.buffer || !sharp.format.jxl.output.buffer }, () => { + test('should read animated image', async (t) => { + t.plan(5); + const { format, pages, pageHeight, loop, delay } = await sharp(fixtures.inputJxlAnimated, { pages: -1 }).metadata(); + t.assert.strictEqual(format, 'jxl'); + t.assert.strictEqual(pages, 30); + t.assert.strictEqual(pageHeight, 80); + t.assert.strictEqual(loop, 0); + t.assert.deepStrictEqual(delay, Array(30).fill(30)); + }); + test('should repeat a single delay for all frames', async (t) => { + t.plan(1); + const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 }) + .jxl({ delay: 100 }) + .toBuffer(); + const updated = await sharp(data, { pages: -1 }).metadata(); + + t.assert.deepStrictEqual(updated.delay, Array(updated.pages).fill(100)); + }); + test('should limit animation loop', async (t) => { + t.plan(1); + const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 }) + .jxl({ loop: 3 }) + .toBuffer(); + const updated = await sharp(data, { pages: -1 }).metadata(); + + t.assert.strictEqual(updated.loop, 3); + }); + test('should change delay between frames', async (t) => { + t.plan(1); + const original = await sharp(fixtures.inputJxlAnimated, { pages: -1 }).metadata(); + + const expectedDelay = [...Array(original.pages).fill(40)]; + const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 }) + .jxl({ delay: expectedDelay }) + .toBuffer(); + const updated = await sharp(data, { pages: -1 }).metadata(); + + t.assert.deepStrictEqual(updated.delay, expectedDelay); + }); + test('should preserve delay between frames', async (t) => { + t.plan(1); + const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 }) + .jxl() + .toBuffer(); + const updated = await sharp(data, { pages: -1 }).metadata(); + + t.assert.deepStrictEqual(updated.delay, Array(30).fill(30)); + }); + test('should round trip to file', async (t) => { + t.plan(3); + const output = fixtures.path('output.animated.jxl'); + await sharp(fixtures.inputJxlAnimated, { pages: -1 }).jxl({ loop: 3 }).toFile(output); + const { pages, loop, delay } = await sharp(output, { pages: -1 }).metadata(); + t.assert.strictEqual(pages, 30); + t.assert.strictEqual(loop, 3); + t.assert.deepStrictEqual(delay, Array(30).fill(30)); + }); + test('should resize animated image to page height', async (t) => { + t.plan(2); + const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 }) + .resize({ height: 40 }) + .jxl({ effort: 1 }) + .toBuffer(); + const updated = await sharp(data, { pages: -1 }).metadata(); + + t.assert.strictEqual(updated.height, 40 * 30); + t.assert.strictEqual(updated.pageHeight, 40); + }); + }); });