From 78829bc29303ca8ca4aa7028191f47dd82bb6fcf Mon Sep 17 00:00:00 2001 From: Tomasz Janowski Date: Thu, 8 Oct 2026 06:41:58 +0000 Subject: [PATCH] Keep animation properties when saving JPEG XL Allows animated JXL output (loop/delay) now that libvips supports it (8.15+ with libjxl). --- src/common.cc | 1 + src/pipeline.cc | 2 - test/fixtures/index.js | 1 + test/fixtures/rotating-squares.jxl | Bin 0 -> 5576 bytes test/unit/jxl.js | 90 +++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/rotating-squares.jxl 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 0000000000000000000000000000000000000000..b305dfbd2ef69ac2da3b656dd9a13f91304275e3 GIT binary patch literal 5576 zcmey*70kjMA%v+V8)p=^c176#(FyprQuJBv zmp=^8Efbcg^4xte!IdRqt9fd&%7TsCyd+f=OcK*%ngjGDK2BcTFf%C0l)0$(|4&y zr?;zUsD$)1dWdA-s0gr~mtsBj!0ECE^8nEeowIZt4s^;&D4o6^{&C(Ef!XDna|D!{ zzUCf3v+?;6aXGPDE7v{sx-RX-{pZ^S?|VC~v`fxT%6P*J$pNFT9t~^!DdYr`_d=yr z(HF|g|8H+Q;QCETfP;lgWl_TkQHhTE{X&NG)_rv9Xee|w&S%VIU@{b8V2U}qsrK~w z>$hxk|K!g;8@GkqML%f*-xPTXv!cUYhG(W2?q73d&yUqUNe}uy$-UUoXzkT-z3#<{ ztQ7U6^2n$+x3(zxS%2i1#OA^4bWAy>W0Fj{^vXJJn~(QhTz^&=na-z}5IV1AsjG^PfRCna>IvCJJ$j^$}q0u5#?w zc_|^To#LRTE~`+W!xJu^BWE2uCHyxc6@dwcf{ZZ_cRk>*y52p_&a`%aZP@2mEzR7b zE=%+b|EujjnX-9pSd%w5YXWbo-zBx_vKJY};%*;T>bUUGY?=F_vw1x(Mh93MgqI!J z?yi=1{w|}Til?RdqeBv6B~5aiy#GG0oe}!gJE_#`dt-^iP1c=JR}Gt<9FjBJdLl?o zb?T>L)2h6#{nnF83h`C^a|+_EMaI^xpQZO2}8>X4u(_D@-{3!`JMY)gt_m5tzvsZI$oX8 zllr>x_N_CWQ+`JnJiK~lla|J`;A?G~6&DUkPvL2}SU#azmaQs8ddcA{U5N)AW+i8Q znYw$E$CX{p%$>*1Fvv{a>)|D|)nk`^LX5QHm12(l>5Y{OZ4}O4Sj_y(_nk;Uhlt8V zJ<(9Mt&4+MHvHOohXImGM%_Ld?xd!f4bb9nLck6U4UNvUj+m~-`7$EFq|U3zbWHT@ z9#$8|8UNxH7WnLVx_VRP%I~XXS4S*X{MvR`tNx*pS^0z3r3Q*88M`YzF1=d1{XSR0 z%@)RmAw@@)n<<)>y`P_^lq%8VH?ymaP2%ukkpiAM)z1W&!c^UFaB;RdIyES6`co_V z-ivc%<@7_Gib@gp%g)GD`_(*5TsZet)Vh+r3%4A|=imEe?&{4>*L6CN*+J6zsGI2! K&hA5f@Dl(OB9%-4 literal 0 HcmV?d00001 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); + }); + }); });