Skip to content

Commit 6dff013

Browse files
authored
Keep animation properties when saving JPEG XL (#4632)
Requires libvips built with support for libjxl
1 parent 59fb17b commit 6dff013

5 files changed

Lines changed: 92 additions & 2 deletions

File tree

‎src/common.cc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ namespace sharp {
399399
imageType == ImageType::JP2 ||
400400
imageType == ImageType::TIFF ||
401401
imageType == ImageType::HEIF ||
402+
imageType == ImageType::JXL ||
402403
imageType == ImageType::PDF;
403404
}
404405

‎src/pipeline.cc‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,7 +1201,6 @@ class PipelineWorker : public Napi::AsyncWorker {
12011201
} else if (baton->formatOut == "jxl" ||
12021202
(baton->formatOut == "input" && inputImageType == sharp::ImageType::JXL)) {
12031203
// Write JXL to buffer
1204-
image = sharp::RemoveAnimationProperties(image);
12051204
VipsArea *area = reinterpret_cast<VipsArea*>(image.jxlsave_buffer(VImage::option()
12061205
->set("keep", baton->keepMetadata)
12071206
->set("distance", baton->jxlDistance)
@@ -1393,7 +1392,6 @@ class PipelineWorker : public Napi::AsyncWorker {
13931392
} else if (baton->formatOut == "jxl" || (mightMatchInput && isJxl) ||
13941393
(willMatchInput && inputImageType == sharp::ImageType::JXL)) {
13951394
// Write JXL to file
1396-
image = sharp::RemoveAnimationProperties(image);
13971395
image.jxlsave(const_cast<char*>(baton->fileOut.data()), VImage::option()
13981396
->set("keep", baton->keepMetadata)
13991397
->set("distance", baton->jxlDistance)

‎test/fixtures/index.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ module.exports = {
125125
inputGifGreyPlusAlpha: getPath('grey-plus-alpha.gif'), // http://i.imgur.com/gZ5jlmE.gif
126126
inputGifAnimated: getPath('rotating-squares.gif'), // CC0 https://loading.io/spinner/blocks/-rotating-squares-preloader-gif
127127
inputGifAnimatedLoop3: getPath('animated-loop-3.gif'), // CC-BY-SA-4.0 Petrus3743 https://commons.wikimedia.org/wiki/File:01-Goldener_Schnitt_Formel-Animation.gif
128+
inputJxlAnimated: getPath('rotating-squares.jxl'), // CC0 https://loading.io/spinner/blocks/-rotating-squares-preloader-gif
128129
inputSvg: getPath('check.svg'), // http://dev.w3.org/SVG/tools/svgweb/samples/svg-files/check.svg
129130
inputSvgSmallViewBox: getPath('circle.svg'),
130131
inputSvgWithEmbeddedImages: getPath('struct-image-04-t.svg'), // https://dev.w3.org/SVG/profiles/1.2T/test/svg/struct-image-04-t.svg

‎test/fixtures/rotating-squares.jxl‎

5.45 KB
Binary file not shown.

‎test/unit/jxl.js‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
const { suite, test } = require('node:test');
77

88
const sharp = require('../../');
9+
const fixtures = require('../fixtures');
910

1011
suite('JXL', () => {
1112
test('called without options does not throw an error', (t) => {
@@ -114,4 +115,93 @@ suite('JXL', () => {
114115
sharp().jxl({ effort: 'fail' });
115116
});
116117
});
118+
test('invalid loop throws', (t) => {
119+
t.plan(2);
120+
t.assert.throws(() => {
121+
sharp().jxl({ loop: -1 });
122+
});
123+
t.assert.throws(() => {
124+
sharp().jxl({ loop: 65536 });
125+
});
126+
});
127+
test('invalid delay throws', (t) => {
128+
t.plan(2);
129+
t.assert.throws(() => {
130+
sharp().jxl({ delay: -1 });
131+
});
132+
t.assert.throws(() => {
133+
sharp().jxl({ delay: [65536] });
134+
});
135+
});
136+
137+
suite('animated', { skip: !sharp.format.jxl.input.buffer || !sharp.format.jxl.output.buffer }, () => {
138+
test('should read animated image', async (t) => {
139+
t.plan(5);
140+
const { format, pages, pageHeight, loop, delay } = await sharp(fixtures.inputJxlAnimated, { pages: -1 }).metadata();
141+
t.assert.strictEqual(format, 'jxl');
142+
t.assert.strictEqual(pages, 30);
143+
t.assert.strictEqual(pageHeight, 80);
144+
t.assert.strictEqual(loop, 0);
145+
t.assert.deepStrictEqual(delay, Array(30).fill(30));
146+
});
147+
test('should repeat a single delay for all frames', async (t) => {
148+
t.plan(1);
149+
const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 })
150+
.jxl({ delay: 100 })
151+
.toBuffer();
152+
const updated = await sharp(data, { pages: -1 }).metadata();
153+
154+
t.assert.deepStrictEqual(updated.delay, Array(updated.pages).fill(100));
155+
});
156+
test('should limit animation loop', async (t) => {
157+
t.plan(1);
158+
const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 })
159+
.jxl({ loop: 3 })
160+
.toBuffer();
161+
const updated = await sharp(data, { pages: -1 }).metadata();
162+
163+
t.assert.strictEqual(updated.loop, 3);
164+
});
165+
test('should change delay between frames', async (t) => {
166+
t.plan(1);
167+
const original = await sharp(fixtures.inputJxlAnimated, { pages: -1 }).metadata();
168+
169+
const expectedDelay = [...Array(original.pages).fill(40)];
170+
const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 })
171+
.jxl({ delay: expectedDelay })
172+
.toBuffer();
173+
const updated = await sharp(data, { pages: -1 }).metadata();
174+
175+
t.assert.deepStrictEqual(updated.delay, expectedDelay);
176+
});
177+
test('should preserve delay between frames', async (t) => {
178+
t.plan(1);
179+
const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 })
180+
.jxl()
181+
.toBuffer();
182+
const updated = await sharp(data, { pages: -1 }).metadata();
183+
184+
t.assert.deepStrictEqual(updated.delay, Array(30).fill(30));
185+
});
186+
test('should round trip to file', async (t) => {
187+
t.plan(3);
188+
const output = fixtures.path('output.animated.jxl');
189+
await sharp(fixtures.inputJxlAnimated, { pages: -1 }).jxl({ loop: 3 }).toFile(output);
190+
const { pages, loop, delay } = await sharp(output, { pages: -1 }).metadata();
191+
t.assert.strictEqual(pages, 30);
192+
t.assert.strictEqual(loop, 3);
193+
t.assert.deepStrictEqual(delay, Array(30).fill(30));
194+
});
195+
test('should resize animated image to page height', async (t) => {
196+
t.plan(2);
197+
const data = await sharp(fixtures.inputJxlAnimated, { pages: -1 })
198+
.resize({ height: 40 })
199+
.jxl({ effort: 1 })
200+
.toBuffer();
201+
const updated = await sharp(data, { pages: -1 }).metadata();
202+
203+
t.assert.strictEqual(updated.height, 40 * 30);
204+
t.assert.strictEqual(updated.pageHeight, 40);
205+
});
206+
});
117207
});

0 commit comments

Comments
 (0)