From 8c53d499f7029133d940fcf58f0be76bb1394395 Mon Sep 17 00:00:00 2001 From: Lovell Fuller Date: Sun, 15 Jun 2025 15:39:01 +0100 Subject: [PATCH] Expose keepDuplicateFrames GIF output parameter --- docs/src/content/docs/api-output.md | 1 + docs/src/content/docs/changelog.md | 2 ++ lib/constructor.js | 1 + lib/index.d.ts | 6 ++++-- lib/output.js | 8 ++++++++ src/pipeline.cc | 5 +++++ src/pipeline.h | 2 ++ test/types/sharp.test-d.ts | 2 ++ test/unit/gif.js | 25 +++++++++++++++++++++++++ 9 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/api-output.md b/docs/src/content/docs/api-output.md index 9cca1d55..40cc74cf 100644 --- a/docs/src/content/docs/api-output.md +++ b/docs/src/content/docs/api-output.md @@ -496,6 +496,7 @@ The palette of the input image will be re-used if possible. | [options.dither] | number | 1.0 | level of Floyd-Steinberg error diffusion, between 0 (least) and 1 (most) | | [options.interFrameMaxError] | number | 0 | maximum inter-frame error for transparency, between 0 (lossless) and 32 | | [options.interPaletteMaxError] | number | 3 | maximum inter-palette error for palette reuse, between 0 and 256 | +| [options.keepDuplicateFrames] | boolean | false | keep duplicate frames in the output instead of combining them | | [options.loop] | number | 0 | number of animation iterations, use 0 for infinite animation | | [options.delay] | number \| Array.<number> | | delay(s) between animation frames (in milliseconds) | | [options.force] | boolean | true | force GIF output, otherwise attempt to use input format | diff --git a/docs/src/content/docs/changelog.md b/docs/src/content/docs/changelog.md index 88c82293..b3c068b7 100644 --- a/docs/src/content/docs/changelog.md +++ b/docs/src/content/docs/changelog.md @@ -12,6 +12,8 @@ Requires libvips v8.17.0 * Add "Magic Kernel Sharp" (no relation) to resizing kernels. +* Expose `keepDuplicateFrames` GIF output parameter. + * Expose JPEG 2000 `oneshot` decoder option. [#4262](https://github.com/lovell/sharp/pull/4262) [@mbklein](https://github.com/mbklein) diff --git a/lib/constructor.js b/lib/constructor.js index c70bbf1e..2cf4c3b3 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -338,6 +338,7 @@ const Sharp = function (input, options) { gifDither: 1, gifInterFrameMaxError: 0, gifInterPaletteMaxError: 3, + gifKeepDuplicateFrames: false, gifReuse: true, gifProgressive: false, tiffQuality: 80, diff --git a/lib/index.d.ts b/lib/index.d.ts index 2d93ac84..2a355426 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1392,9 +1392,11 @@ declare namespace sharp { /** Level of Floyd-Steinberg error diffusion, between 0 (least) and 1 (most) (optional, default 1.0) */ dither?: number | undefined; /** Maximum inter-frame error for transparency, between 0 (lossless) and 32 (optional, default 0) */ - interFrameMaxError?: number; + interFrameMaxError?: number | undefined; /** Maximum inter-palette error for palette reuse, between 0 and 256 (optional, default 3) */ - interPaletteMaxError?: number; + interPaletteMaxError?: number | undefined; + /** Keep duplicate frames in the output instead of combining them (optional, default false) */ + keepDuplicateFrames?: boolean | undefined; } interface TiffOptions extends OutputOptions { diff --git a/lib/output.js b/lib/output.js index 08d596ad..a8b32277 100644 --- a/lib/output.js +++ b/lib/output.js @@ -729,6 +729,7 @@ function webp (options) { * @param {number} [options.dither=1.0] - level of Floyd-Steinberg error diffusion, between 0 (least) and 1 (most) * @param {number} [options.interFrameMaxError=0] - maximum inter-frame error for transparency, between 0 (lossless) and 32 * @param {number} [options.interPaletteMaxError=3] - maximum inter-palette error for palette reuse, between 0 and 256 + * @param {boolean} [options.keepDuplicateFrames=false] - keep duplicate frames in the output instead of combining them * @param {number} [options.loop=0] - number of animation iterations, use 0 for infinite animation * @param {number|number[]} [options.delay] - delay(s) between animation frames (in milliseconds) * @param {boolean} [options.force=true] - force GIF output, otherwise attempt to use input format @@ -779,6 +780,13 @@ function gif (options) { throw is.invalidParameterError('interPaletteMaxError', 'number between 0.0 and 256.0', options.interPaletteMaxError); } } + if (is.defined(options.keepDuplicateFrames)) { + if (is.bool(options.keepDuplicateFrames)) { + this._setBooleanOption('gifKeepDuplicateFrames', options.keepDuplicateFrames); + } else { + throw is.invalidParameterError('keepDuplicateFrames', 'boolean', options.keepDuplicateFrames); + } + } } trySetAnimationOptions(options, this.options); return this._updateFormatOut('gif', options); diff --git a/src/pipeline.cc b/src/pipeline.cc index 679c8329..e353cc72 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -1006,6 +1006,7 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("interlace", baton->gifProgressive) ->set("interframe_maxerror", baton->gifInterFrameMaxError) ->set("interpalette_maxerror", baton->gifInterPaletteMaxError) + ->set("keep_duplicate_frames", baton->gifKeepDuplicateFrames) ->set("dither", baton->gifDither))); baton->bufferOut = static_cast(area->data); baton->bufferOutLength = area->length; @@ -1209,6 +1210,9 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("effort", baton->gifEffort) ->set("reuse", baton->gifReuse) ->set("interlace", baton->gifProgressive) + ->set("interframe_maxerror", baton->gifInterFrameMaxError) + ->set("interpalette_maxerror", baton->gifInterPaletteMaxError) + ->set("keep_duplicate_frames", baton->gifKeepDuplicateFrames) ->set("dither", baton->gifDither)); baton->formatOut = "gif"; } else if (baton->formatOut == "tiff" || (mightMatchInput && isTiff) || @@ -1761,6 +1765,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->gifDither = sharp::AttrAsDouble(options, "gifDither"); baton->gifInterFrameMaxError = sharp::AttrAsDouble(options, "gifInterFrameMaxError"); baton->gifInterPaletteMaxError = sharp::AttrAsDouble(options, "gifInterPaletteMaxError"); + baton->gifKeepDuplicateFrames = sharp::AttrAsBool(options, "gifKeepDuplicateFrames"); baton->gifReuse = sharp::AttrAsBool(options, "gifReuse"); baton->gifProgressive = sharp::AttrAsBool(options, "gifProgressive"); baton->tiffQuality = sharp::AttrAsUint32(options, "tiffQuality"); diff --git a/src/pipeline.h b/src/pipeline.h index 66a45f43..63c9f7c2 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -169,6 +169,7 @@ struct PipelineBaton { double gifDither; double gifInterFrameMaxError; double gifInterPaletteMaxError; + bool gifKeepDuplicateFrames; bool gifReuse; bool gifProgressive; int tiffQuality; @@ -342,6 +343,7 @@ struct PipelineBaton { gifDither(1.0), gifInterFrameMaxError(0.0), gifInterPaletteMaxError(3.0), + gifKeepDuplicateFrames(false), gifReuse(true), gifProgressive(false), tiffQuality(80), diff --git a/test/types/sharp.test-d.ts b/test/types/sharp.test-d.ts index be8ce88d..eb1b3bb7 100644 --- a/test/types/sharp.test-d.ts +++ b/test/types/sharp.test-d.ts @@ -375,6 +375,8 @@ sharp(input) .gif({ reuse: false }) .gif({ progressive: true }) .gif({ progressive: false }) + .gif({ keepDuplicateFrames: true }) + .gif({ keepDuplicateFrames: false }) .toBuffer({ resolveWithObject: true }) .then(({ data, info }) => { console.log(data); diff --git a/test/unit/gif.js b/test/unit/gif.js index 93e4d46b..61a3e199 100644 --- a/test/unit/gif.js +++ b/test/unit/gif.js @@ -187,6 +187,17 @@ describe('GIF input', () => { ); }); + it('invalid keepDuplicateFrames throws', () => { + assert.throws( + () => sharp().gif({ keepDuplicateFrames: -1 }), + /Expected boolean for keepDuplicateFrames but received -1 of type number/ + ); + assert.throws( + () => sharp().gif({ keepDuplicateFrames: 'fail' }), + /Expected boolean for keepDuplicateFrames but received fail of type string/ + ); + }); + it('should work with streams when only animated is set', function (done) { fs.createReadStream(fixtures.inputGifAnimated) .pipe(sharp({ animated: true })) @@ -225,6 +236,20 @@ describe('GIF input', () => { assert.strict(before.length > after.length); }); + it('should keep duplicate frames via keepDuplicateFrames', async () => { + const create = { width: 8, height: 8, channels: 4, background: 'blue' }; + const input = sharp([{ create }, { create }], { join: { animated: true } }); + + const before = await input.gif({ keepDuplicateFrames: false }).toBuffer(); + const after = await input.gif({ keepDuplicateFrames: true }).toBuffer(); + assert.strict(before.length < after.length); + + const beforeMeta = await sharp(before).metadata(); + const afterMeta = await sharp(after).metadata(); + assert.strictEqual(beforeMeta.pages, 1); + assert.strictEqual(afterMeta.pages, 2); + }); + it('non-animated input defaults to no-loop', async () => { for (const input of [fixtures.inputGif, fixtures.inputPng]) { const data = await sharp(input)