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)