diff --git a/lib/output.js b/lib/output.js index 7e36a35c..27594db0 100644 --- a/lib/output.js +++ b/lib/output.js @@ -11,7 +11,8 @@ const formats = new Map([ ['png', 'png'], ['raw', 'raw'], ['tiff', 'tiff'], - ['webp', 'webp'] + ['webp', 'webp'], + ['gif', 'gif'] ]); /** @@ -340,6 +341,9 @@ function png (options) { * @param {boolean} [options.nearLossless=false] - use near_lossless compression mode * @param {boolean} [options.smartSubsample=false] - use high quality chroma subsampling * @param {number} [options.reductionEffort=4] - level of CPU effort to reduce file size, integer 0-6 + * @param {number} [options.pageHeight] - page height for animated output + * @param {number} [options.loop=0] - number of animation iterations, use 0 for infinite animation + * @param {number[]} [options.delay] - list of delays between animation frames (in milliseconds) * @param {boolean} [options.force=true] - force WebP output, otherwise attempt to use input format * @returns {Sharp} * @throws {Error} Invalid options @@ -375,9 +379,66 @@ function webp (options) { throw is.invalidParameterError('reductionEffort', 'integer between 0 and 6', options.reductionEffort); } } + + trySetAnimationOptions(options, this.options); return this._updateFormatOut('webp', options); } +/** + * Use these GIF options for output image. + * + * Requires a custom, globally-installed libvips compiled with support for imageMagick. + * + * @param {Object} [options] - output options + * @param {number} [options.pageHeight] - page height for animated output + * @param {number} [options.loop=0] - number of animation iterations, use 0 for infinite animation + * @param {number[]} [options.delay] - list of delays between animation frames (in milliseconds) + * @param {boolean} [options.force=true] - force GIF output, otherwise attempt to use input format + * @returns {Sharp} + * @throws {Error} Invalid options + */ +function gif (options) { + trySetAnimationOptions(options, this.options); + return this._updateFormatOut('gif', options); +} + +/** + * Set animation options if available. + * + * @param {Object} [source] - output options + * @param {number} [source.pageHeight] - page height for animated output + * @param {number} [source.loop=0] - number of animation iterations, use 0 for infinite animation + * @param {number[]} [source.delay] - list of delays between animation frames (in milliseconds) + * @param {Object} [target] - target object for valid options + * @throws {Error} Invalid options + */ +function trySetAnimationOptions (source, target) { + if (is.object(source) && is.defined(source.pageHeight)) { + if (is.integer(source.pageHeight) && source.pageHeight > 0) { + target.pageHeight = source.pageHeight; + } else { + throw is.invalidParameterError('pageHeight', 'integer larger than 0', source.pageHeight); + } + } + if (is.object(source) && is.defined(source.loop)) { + if (is.integer(source.loop) && is.inRange(source.loop, 0, 65535)) { + target.loop = source.loop; + } else { + throw is.invalidParameterError('loop', 'integer between 0 and 65535', source.loop); + } + } + if (is.object(source) && is.defined(source.delay)) { + if ( + Array.isArray(source.delay) && + source.delay.every(is.integer) && + source.delay.every(v => is.inRange(v, 0, 65535))) { + target.delay = source.delay; + } else { + throw is.invalidParameterError('delay', 'array of integers between 0 and 65535', source.delay); + } + } +} + /** * Use these TIFF options for output image. * @@ -808,6 +869,7 @@ module.exports = function (Sharp) { webp, tiff, heif, + gif, raw, tile, // Private diff --git a/package.json b/package.json index f6c73d79..4e04fe62 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ "Brendan Kennedy ", "Brychan Bennett-Odlum ", "Edward Silverton ", - "Roman Malieiev " + "Roman Malieiev ", + "Tomas Szabo " ], "scripts": { "install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node-gyp rebuild && node install/dll-copy)", diff --git a/src/common.cc b/src/common.cc index 7ae87cd5..1bd5b6be 100644 --- a/src/common.cc +++ b/src/common.cc @@ -42,6 +42,9 @@ namespace sharp { int32_t AttrAsInt32(Napi::Object obj, std::string attr) { return obj.Get(attr).As().Int32Value(); } + int32_t AttrAsInt32(Napi::Object obj, unsigned int const attr) { + return obj.Get(attr).As().Int32Value(); + } double AttrAsDouble(Napi::Object obj, std::string attr) { return obj.Get(attr).As().DoubleValue(); } @@ -59,6 +62,14 @@ namespace sharp { } return rgba; } + std::vector AttrAsInt32Vector(Napi::Object obj, std::string attr) { + Napi::Array array = obj.Get(attr).As(); + std::vector vector(array.Length()); + for (unsigned int i = 0; i < array.Length(); i++) { + vector[i] = AttrAsInt32(array, i); + } + return vector; + } // Create an InputDescriptor instance from a Napi::Object describing an input image InputDescriptor* CreateInputDescriptor(Napi::Object input) { @@ -126,6 +137,9 @@ namespace sharp { bool IsWebp(std::string const &str) { return EndsWith(str, ".webp") || EndsWith(str, ".WEBP"); } + bool IsGif(std::string const &str) { + return EndsWith(str, ".gif") || EndsWith(str, ".GIF"); + } bool IsTiff(std::string const &str) { return EndsWith(str, ".tif") || EndsWith(str, ".tiff") || EndsWith(str, ".TIF") || EndsWith(str, ".TIFF"); } @@ -239,6 +253,7 @@ namespace sharp { */ bool ImageTypeSupportsPage(ImageType imageType) { return + imageType == ImageType::WEBP || imageType == ImageType::MAGICK || imageType == ImageType::GIF || imageType == ImageType::TIFF || @@ -408,6 +423,38 @@ namespace sharp { return copy; } + /* + Set animation properties if necessary. + Non-provided properties will be loaded from image. + */ + VImage SetAnimationProperties(VImage image, int pageHeight, std::vector delay, int loop) { + bool hasDelay = delay.size() != 1 || delay.front() != -1; + + if (pageHeight == 0 && image.get_typeof(VIPS_META_PAGE_HEIGHT) == G_TYPE_INT) { + pageHeight = image.get_int(VIPS_META_PAGE_HEIGHT); + } + + if (!hasDelay && image.get_typeof("delay") == VIPS_TYPE_ARRAY_INT) { + delay = image.get_array_int("delay"); + hasDelay = true; + } + + if (loop == -1 && image.get_typeof("loop") == G_TYPE_INT) { + loop = image.get_int("loop"); + } + + if (pageHeight == 0) return image; + + // It is necessary to create the copy as otherwise, pageHeight will be ignored! + VImage copy = image.copy(); + + copy.set(VIPS_META_PAGE_HEIGHT, pageHeight); + if (hasDelay) copy.set("delay", delay); + if (loop != -1) copy.set("loop", loop); + + return copy; + } + /* Does this image have a non-default density? */ @@ -446,6 +493,11 @@ namespace sharp { if (image.width() > 16383 || image.height() > 16383) { throw vips::VError("Processed image is too large for the WebP format"); } + } else if (imageType == ImageType::GIF) { + const int height = image.get_typeof("pageHeight") == G_TYPE_INT ? image.get_int("pageHeight") : image.height(); + if (image.width() > 65535 || height > 65535) { + throw vips::VError("Processed image is too large for the GIF format"); + } } } diff --git a/src/common.h b/src/common.h index 499066c7..0822cad5 100644 --- a/src/common.h +++ b/src/common.h @@ -88,10 +88,12 @@ namespace sharp { std::string AttrAsStr(Napi::Object obj, std::string attr); uint32_t AttrAsUint32(Napi::Object obj, std::string attr); int32_t AttrAsInt32(Napi::Object obj, std::string attr); + int32_t AttrAsInt32(Napi::Object obj, unsigned int const attr); double AttrAsDouble(Napi::Object obj, std::string attr); double AttrAsDouble(Napi::Object obj, unsigned int const attr); bool AttrAsBool(Napi::Object obj, std::string attr); std::vector AttrAsRgba(Napi::Object obj, std::string attr); + std::vector AttrAsInt32Vector(Napi::Object obj, std::string attr); // Create an InputDescriptor instance from a Napi::Object describing an input image InputDescriptor* CreateInputDescriptor(Napi::Object input); @@ -125,6 +127,7 @@ namespace sharp { bool IsJpeg(std::string const &str); bool IsPng(std::string const &str); bool IsWebp(std::string const &str); + bool IsGif(std::string const &str); bool IsTiff(std::string const &str); bool IsHeic(std::string const &str); bool IsHeif(std::string const &str); @@ -184,6 +187,12 @@ namespace sharp { */ VImage RemoveExifOrientation(VImage image); + /* + Set animation properties if necessary. + Non-provided properties will be loaded from image. + */ + VImage SetAnimationProperties(VImage image, int pageHeight, std::vector delay, int loop); + /* Does this image have a non-default density? */ diff --git a/src/pipeline.cc b/src/pipeline.cc index 8c1cb9bc..21ff0063 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -693,6 +693,16 @@ class PipelineWorker : public Napi::AsyncWorker { baton->channels = image.bands(); baton->width = image.width(); baton->height = image.height(); + + bool const supportsGifOutput = vips_type_find("VipsOperation", "magicksave") != 0 && + vips_type_find("VipsOperation", "magicksave_buffer") != 0; + + image = sharp::SetAnimationProperties( + image, + baton->pageHeight, + baton->delay, + baton->loop); + // Output if (baton->fileOut.empty()) { // Buffer output @@ -722,8 +732,8 @@ class PipelineWorker : public Napi::AsyncWorker { baton->channels = std::min(baton->channels, 3); } } else if (baton->formatOut == "png" || (baton->formatOut == "input" && - (inputImageType == sharp::ImageType::PNG || inputImageType == sharp::ImageType::GIF || - inputImageType == sharp::ImageType::SVG))) { + (inputImageType == sharp::ImageType::PNG || (inputImageType == sharp::ImageType::GIF && !supportsGifOutput) || + inputImageType == sharp::ImageType::SVG))) { // Write PNG to buffer sharp::AssertImageTypeDimensions(image, sharp::ImageType::PNG); VipsArea *area = VIPS_AREA(image.pngsave_buffer(VImage::option() @@ -757,6 +767,18 @@ class PipelineWorker : public Napi::AsyncWorker { area->free_fn = nullptr; vips_area_unref(area); baton->formatOut = "webp"; + } else if (baton->formatOut == "gif" || + (baton->formatOut == "input" && inputImageType == sharp::ImageType::GIF && supportsGifOutput)) { + // Write GIF to buffer + sharp::AssertImageTypeDimensions(image, sharp::ImageType::GIF); + VipsArea *area = VIPS_AREA(image.magicksave_buffer(VImage::option() + ->set("strip", !baton->withMetadata) + ->set("format", "gif"))); + baton->bufferOut = static_cast(area->data); + baton->bufferOutLength = area->length; + area->free_fn = nullptr; + vips_area_unref(area); + baton->formatOut = "gif"; } else if (baton->formatOut == "tiff" || (baton->formatOut == "input" && inputImageType == sharp::ImageType::TIFF)) { // Write TIFF to buffer @@ -832,13 +854,16 @@ class PipelineWorker : public Napi::AsyncWorker { bool const isJpeg = sharp::IsJpeg(baton->fileOut); bool const isPng = sharp::IsPng(baton->fileOut); bool const isWebp = sharp::IsWebp(baton->fileOut); + bool const isGif = sharp::IsGif(baton->fileOut); bool const isTiff = sharp::IsTiff(baton->fileOut); bool const isHeif = sharp::IsHeif(baton->fileOut); bool const isDz = sharp::IsDz(baton->fileOut); bool const isDzZip = sharp::IsDzZip(baton->fileOut); bool const isV = sharp::IsV(baton->fileOut); bool const mightMatchInput = baton->formatOut == "input"; - bool const willMatchInput = mightMatchInput && !(isJpeg || isPng || isWebp || isTiff || isDz || isDzZip || isV); + bool const willMatchInput = mightMatchInput && + !(isJpeg || isPng || isWebp || isGif || isTiff || isDz || isDzZip || isV); + if (baton->formatOut == "jpeg" || (mightMatchInput && isJpeg) || (willMatchInput && inputImageType == sharp::ImageType::JPEG)) { // Write JPEG to file @@ -858,8 +883,8 @@ class PipelineWorker : public Napi::AsyncWorker { baton->formatOut = "jpeg"; baton->channels = std::min(baton->channels, 3); } else if (baton->formatOut == "png" || (mightMatchInput && isPng) || (willMatchInput && - (inputImageType == sharp::ImageType::PNG || inputImageType == sharp::ImageType::GIF || - inputImageType == sharp::ImageType::SVG))) { + (inputImageType == sharp::ImageType::PNG || (inputImageType == sharp::ImageType::GIF && !supportsGifOutput) || + inputImageType == sharp::ImageType::SVG))) { // Write PNG to file sharp::AssertImageTypeDimensions(image, sharp::ImageType::PNG); image.pngsave(const_cast(baton->fileOut.data()), VImage::option() @@ -885,6 +910,14 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("reduction_effort", baton->webpReductionEffort) ->set("alpha_q", baton->webpAlphaQuality)); baton->formatOut = "webp"; + } else if (baton->formatOut == "gif" || (mightMatchInput && isGif) || + (willMatchInput && inputImageType == sharp::ImageType::GIF && supportsGifOutput)) { + // Write GIF to file + sharp::AssertImageTypeDimensions(image, sharp::ImageType::GIF); + image.magicksave(const_cast(baton->fileOut.data()), VImage::option() + ->set("strip", !baton->withMetadata) + ->set("format", "gif")); + baton->formatOut = "gif"; } else if (baton->formatOut == "tiff" || (mightMatchInput && isTiff) || (willMatchInput && inputImageType == sharp::ImageType::TIFF)) { // Write TIFF to file @@ -1328,6 +1361,18 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->heifCompression = static_cast( vips_enum_from_nick(nullptr, VIPS_TYPE_FOREIGN_HEIF_COMPRESSION, sharp::AttrAsStr(options, "heifCompression").data())); + + // Animated output + if (sharp::HasAttr(options, "pageHeight")) { + baton->pageHeight = sharp::AttrAsUint32(options, "pageHeight"); + } + if (sharp::HasAttr(options, "loop")) { + baton->loop = sharp::AttrAsUint32(options, "loop"); + } + if (sharp::HasAttr(options, "delay")) { + baton->delay = sharp::AttrAsInt32Vector(options, "delay"); + } + // Tile output baton->tileSize = sharp::AttrAsUint32(options, "tileSize"); baton->tileOverlap = sharp::AttrAsUint32(options, "tileOverlap"); diff --git a/src/pipeline.h b/src/pipeline.h index 722f7cf7..650e5f58 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -167,6 +167,9 @@ struct PipelineBaton { bool removeAlpha; bool ensureAlpha; VipsInterpretation colourspace; + int pageHeight; + std::vector delay; + int loop; int tileSize; int tileOverlap; VipsForeignDzContainer tileContainer; @@ -273,6 +276,9 @@ struct PipelineBaton { removeAlpha(false), ensureAlpha(false), colourspace(VIPS_INTERPRETATION_LAST), + pageHeight(0), + delay{-1}, + loop(-1), tileSize(256), tileOverlap(0), tileContainer(VIPS_FOREIGN_DZ_CONTAINER_FS), diff --git a/test/fixtures/animated-loop-3.webp b/test/fixtures/animated-loop-3.webp new file mode 100644 index 00000000..2ea7f77a Binary files /dev/null and b/test/fixtures/animated-loop-3.webp differ diff --git a/test/fixtures/index.js b/test/fixtures/index.js index 0e6a2c9a..9557f4a2 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -93,6 +93,8 @@ module.exports = { inputWebP: getPath('4.webp'), // http://www.gstatic.com/webp/gallery/4.webp inputWebPWithTransparency: getPath('5_webp_a.webp'), // http://www.gstatic.com/webp/gallery3/5_webp_a.webp + inputWebPAnimated: getPath('rotating-squares.webp'), // http://www.gstatic.com/webp/gallery3/5_webp_a.webp + inputWebPAnimatedLoop3: getPath('animated-loop-3.webp'), // http://www.gstatic.com/webp/gallery3/5_webp_a.webp inputTiff: getPath('G31D.TIF'), // http://www.fileformat.info/format/tiff/sample/e6c9a6e5253348f4aef6d17b534360ab/index.htm inputTiffMultipage: getPath('G31D_MULTI.TIF'), // gm convert G31D.TIF -resize 50% G31D_2.TIF ; tiffcp G31D.TIF G31D_2.TIF G31D_MULTI.TIF inputTiffCielab: getPath('cielab-dagams.tiff'), // https://github.com/lovell/sharp/issues/646 diff --git a/test/fixtures/rotating-squares.webp b/test/fixtures/rotating-squares.webp new file mode 100644 index 00000000..2d619354 Binary files /dev/null and b/test/fixtures/rotating-squares.webp differ diff --git a/test/unit/gif.js b/test/unit/gif.js index 6c69d34c..e26d566f 100644 --- a/test/unit/gif.js +++ b/test/unit/gif.js @@ -61,4 +61,41 @@ describe('GIF input', () => { assert.strictEqual(4, info.channels); }) ); + + if (!sharp.format.magick.input.buffer) { + it('Animated GIF output should fail due to missing ImageMagick', () => + assert.rejects(() => + sharp(fixtures.inputGifAnimated, { pages: -1 }) + .gif({ loop: 2, delay: [...Array(10).fill(100)], pageHeight: 10 }) + .toBuffer(), + /VipsOperation: class "magicksave_buffer" not found/ + ) + ); + } + + it('invalid pageHeight throws', () => { + assert.throws(() => { + sharp().gif({ pageHeight: 0 }); + }); + }); + + it('invalid loop throws', () => { + assert.throws(() => { + sharp().gif({ loop: -1 }); + }); + + assert.throws(() => { + sharp().gif({ loop: 65536 }); + }); + }); + + it('invalid delay throws', () => { + assert.throws(() => { + sharp().gif({ delay: [-1] }); + }); + + assert.throws(() => { + sharp().gif({ delay: [65536] }); + }); + }); }); diff --git a/test/unit/metadata.js b/test/unit/metadata.js index 181ad7d3..b34cceef 100644 --- a/test/unit/metadata.js +++ b/test/unit/metadata.js @@ -192,6 +192,54 @@ describe('Image metadata', function () { }); }); + it('Animated WebP', () => + sharp(fixtures.inputWebPAnimated) + .metadata() + .then(({ + format, width, height, space, channels, depth, + isProgressive, pages, pageHeight, loop, delay, + hasProfile, hasAlpha + }) => { + assert.strictEqual(format, 'webp'); + assert.strictEqual(width, 80); + assert.strictEqual(height, 80); + assert.strictEqual(space, 'srgb'); + assert.strictEqual(channels, 4); + assert.strictEqual(depth, 'uchar'); + assert.strictEqual(isProgressive, false); + assert.strictEqual(pages, 9); + assert.strictEqual(pageHeight, 80); + assert.strictEqual(loop, 0); + assert.deepStrictEqual(delay, [120, 120, 90, 120, 120, 90, 120, 90, 30]); + assert.strictEqual(hasProfile, false); + assert.strictEqual(hasAlpha, true); + }) + ); + + it('Animated WebP with limited looping', () => + sharp(fixtures.inputWebPAnimatedLoop3) + .metadata() + .then(({ + format, width, height, space, channels, depth, + isProgressive, pages, pageHeight, loop, delay, + hasProfile, hasAlpha + }) => { + assert.strictEqual(format, 'webp'); + assert.strictEqual(width, 370); + assert.strictEqual(height, 285); + assert.strictEqual(space, 'srgb'); + assert.strictEqual(channels, 4); + assert.strictEqual(depth, 'uchar'); + assert.strictEqual(isProgressive, false); + assert.strictEqual(pages, 10); + assert.strictEqual(pageHeight, 285); + assert.strictEqual(loop, 3); + assert.deepStrictEqual(delay, [...Array(9).fill(3000), 15000]); + assert.strictEqual(hasProfile, false); + assert.strictEqual(hasAlpha, true); + }) + ); + it('GIF via giflib', function (done) { sharp(fixtures.inputGif).metadata(function (err, metadata) { if (err) throw err; diff --git a/test/unit/webp.js b/test/unit/webp.js index 7921dfdc..5302bb4f 100644 --- a/test/unit/webp.js +++ b/test/unit/webp.js @@ -125,4 +125,63 @@ describe('WebP', function () { sharp().webp({ reductionEffort: -1 }); }); }); + + it('invalid pageHeight throws', () => { + assert.throws(() => { + sharp().webp({ pageHeight: 0 }); + }); + }); + + it('invalid loop throws', () => { + assert.throws(() => { + sharp().webp({ loop: -1 }); + }); + + assert.throws(() => { + sharp().webp({ loop: 65536 }); + }); + }); + + it('invalid delay throws', () => { + assert.throws(() => { + sharp().webp({ delay: [-1] }); + }); + + assert.throws(() => { + sharp().webp({ delay: [65536] }); + }); + }); + + it('should double the number of frames with default delay', async () => { + const original = await sharp(fixtures.inputWebPAnimated, { pages: -1 }).metadata(); + const updated = await sharp(fixtures.inputWebPAnimated, { pages: -1 }) + .webp({ pageHeight: original.pageHeight / 2 }) + .toBuffer() + .then(data => sharp(data, { pages: -1 }).metadata()); + + assert.strictEqual(updated.pages, original.pages * 2); + assert.strictEqual(updated.pageHeight, original.pageHeight / 2); + assert.deepStrictEqual(updated.delay, [...original.delay, ...Array(9).fill(120)]); + }); + + it('should limit animation loop', async () => { + const updated = await sharp(fixtures.inputWebPAnimated, { pages: -1 }) + .webp({ loop: 3 }) + .toBuffer() + .then(data => sharp(data, { pages: -1 }).metadata()); + + assert.strictEqual(updated.loop, 3); + }); + + it('should change delay between frames', async () => { + const original = await sharp(fixtures.inputWebPAnimated, { pages: -1 }).metadata(); + + const expectedDelay = [...Array(original.pages).fill(40)]; + const updated = await sharp(fixtures.inputWebPAnimated, { pages: -1 }) + .webp({ delay: expectedDelay }) + .toBuffer() + .then(data => sharp(data, { pages: -1 }).metadata()); + + assert.deepStrictEqual(updated.delay, expectedDelay); + }); });