From cb1baede879a4b82397c14bb325f6b029878713e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Szabo?= Date: Mon, 17 Aug 2020 15:48:38 +0200 Subject: [PATCH] Add support for animated WebP and GIF (via magick) (#2012) --- lib/output.js | 64 +++++++++++++++++++++++++++- package.json | 3 +- src/common.cc | 52 ++++++++++++++++++++++ src/common.h | 9 ++++ src/pipeline.cc | 55 +++++++++++++++++++++--- src/pipeline.h | 6 +++ test/fixtures/animated-loop-3.webp | Bin 0 -> 8440 bytes test/fixtures/index.js | 2 + test/fixtures/rotating-squares.webp | Bin 0 -> 6464 bytes test/unit/gif.js | 37 ++++++++++++++++ test/unit/metadata.js | 48 +++++++++++++++++++++ test/unit/webp.js | 59 +++++++++++++++++++++++++ 12 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/animated-loop-3.webp create mode 100644 test/fixtures/rotating-squares.webp 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 0000000000000000000000000000000000000000..2ea7f77aa6cddf8dc8ae9d4ed389de94262cc48d GIT binary patch literal 8440 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 D?xF?+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2d61935496516350ade6bb400ae241189e52ddfa GIT binary patch literal 6464 zcmb7|1yCGamxc!qI!Fi>+zAANyKC?OA%c4d?h+UzxRV5e3@!;aK!S(ClifSZ|{A^v9s007uZItH48H>8PHro`4skQxafeN9woT5R?Ql_4VOUa*0W*4;Ar9dF|ED;ut-1eXSfGf1c&8 z%q&gEHx)w0mY@0Fp$vb)P;n$|Bkyg{+v$uMCgrwd#VX3Z-I;Wk4Dn0pVcskK<#|FN zH8GX!qDg#}DBb05%>#Slq(nPlr~{hriqd&2I-AI(4rUomg40xGu|EPkySIdtb6+1a z@}C2RIz0H)aknnkG10T!-X-Y@f{Hd8xE+*U0Betv^Ltzye$XH)K>M!EUqPg_nzGZ# zm0^)L>e}#?ON;~V&d#*l%Co+R6Owp#H6|eNxI5bul(SIGy=-`_rV$^**1x4|dd3j_ zy5C4Nz}2(TfYJcD*fgB>WY#WZGN~f!LE>_)tGqH6KMk$fK87%9aeHagJI;&&*zap6 zZOfonFB8A$?a&L8CK4LDJFmLDQd-tJA!npjPrI&ik?A&!nWsa z7X1Mxzsj&G-j(8DF`Z>cm4D1Xa#bVZ<5;j;!8P(-BoiNb!jd0kr(!md{?uVSe>GDQ z>xUZ>iNxZ0Q|^6eyQplf>Q6FGIZr~>U6z46E^}or&kp}f#rQb?O~ruUDpvTnigg*Z zMzoeNhI05C`*gDJCbx30Vy~$*=bCXrBu>O*8jOL2;{s_ZVOrz23^bX;wYZmBo4j3d zasH^f>_*j-!=F^M2_ELhgt0k&yqW|r_S15yv`E>$vA8gKO?{~rSWf5gu9cOxFtyi;bb3gAG=q! z=U(d!D%Y&P30^{6y*J<-Fnw#C=;2Lknv+ivokB36qP3FPcmg+p$x^;kUs#$&O`m9a zn*+JCHQmmPTrL;7X$V`)+D~Gz(MkLMsaw(vx8;zSsYbh-{0?me@G=GY zv!jwH=4*bDV}92Fg%hl+fCg~KuF;w{-#)(n^|8+Run!L|=2scJEXogdG3d^89{HXa z6`0rA54wlSvjwRNC>m0Ve`HElu21x)-Pxzw4(!pgbr*gy{2VJRbg13pb} zx!(*gGrJ=Pi?SIkpe}+(6s~ue_;qsHnV9=Ti#bciWT(N|DVon5vw0$TVJ`>G@dx^Z zA3li5?;h0TD3kYp{)8W&KLCk7NINh%#R8X^wpT~EV$TXl9*+jRKBG@10F(_p{&~tz zQ(;c0?-LL)vF(4YqPff2MEI+BJAEXusj?!|ArD~fmGT{JycboZUBRwJWi;6~23R|pERu-f>!B~%wuHkWugi&p^x#qBudz3xOK4wttp;Rck-s>8`cQx8mbF5u z+B-xERN~vVSTQ=})03|APZ9maOwZ7AiH+F!>T(k#TV!E`d z8YA&SDP$y4bZl}e>iw2l5wqYy54W@D1nYCy^{hJ+;(C-JVlxw=KszI$Ar~?CB3bfj zB3TmiHGFwlK{ht#6_~D9GLprPGa^?%tcioc6VCZ;JL04p(Gj74l_ef4*e_oBW#bir zBtH+xmr=0Yr9s(#TPkEjC2_c!*&m9cCsfu{^CKaKJnUXyLE4LA5caj%;|+_WGp0s` z8LkVV5wlc0?%rkgAFj+~`@ihf`eS$B^Fo?D=g+^z4TCLR*;+)6RM$0~CN&2l+ejOk zr1ldKuXGv3GN%@@1&uRHa2MTcyOqfwYaj=~s5$00<*aj`aONuf!R+?4{fOYCkuNy) zbByu5{qu|p=gVmULPKy~ba@+Ou_@6&5C2H1 z!ZCK7&Q;y*9@5d*SgN@>$k=97MTxgYn_Yf>#$4jw4!6cz)`~m&)iyI@9CJi^)MjMGQ8Hvo^M+%7k<$zoN0}qt`=>URC z$&3Hu02e&yH^rB+@;jO$&DI8{PHbf1apqeZR;w*dt+UD&Rss=!A=vu|{t03~rOxs#X1}9X;p)&lxY&cfbwrZW ziD%}r4-+#G=9l`-;I<+xuUXbeGd<%Y_6FOU9^r!zk8f@KBBNHJk}9bClIxiCA~zBA zqb6eP67nEg5;okDAF9K*S4z$gxL%bts9!=nV)8vF;_pzT_sxwj<;H5Q^FPe@b#Rn? znwevV8NP3feU;*8lSNrLvI^1BmYBy+HZ<;kH&$`9*gyWD?1sX2 z*GpU9x+8DQ%lgK31>qSz|`(UR8N9NDak`-KMSRTQo0N=SJP=n!-Ah^=X@bL!od6NSf#f zca2}UmPZ?u{iuY4d|u0M?wm1kySbFjZ;_WcwCyog1ns+`y<3V?h$gN@PYb6+T_BWJ zlQuIPj88{xinvBS{Cby1YbL5GwkZ-}SYTgE({A^tgmh-WfSqsoX+30-+uwv4Vc&-r z+4Sei1nqiWd8ko`j3^J|&M#FhXXaE$h60c3K8Y{MPgJ=&L&$0_t;?-piw&c!O9 za^KdQqs@518|Nq&ox54)9({9pbD38>dM@;{L|8nr%>agL~B_p z54@E-P|N6e5X0qwY0P(BwXhSdD+l&IyzfqxU8pC!t4cNrd3Bs$C}K2%^{lQPd5ght zX-D;5ZvFv-X)3OYU-g59GYHJ;;F8rZDUXr8-9z?~ZJS*(!EFC$bSbztG|KHW1p`%u zj>24Az5H@2?n6B>;)qe-_#nEI7;qO6J}IHRl-f8RFnG01NAo^IQ?7g%b)shCJ;{KlFF^4 z35L8B$m34d;EpK$!bm;lbDZ!>w117**vzJY(XPnXN*022j2vxy>6zgbz5RP8(VA>A zHf`a$`Pc6jOfbUVjsO~W`|Wz8KM2ng4qBs)`tyX%YEnJ#u*Z$Jz0~}{ony2CULPXK zw7S88+rg_Q^dUsW#q_+{9Ce%CF6T0sa5BrNY{iDZSp2bjXiYc`bOKGu2sf%ZlploJ zIAYLYCXvsI{0blfJs9wqZs=UdUkBZJRXQT}G-(zzd z_X~mzvade2RLRX;uh8V>7%p`vtuu5?ye1(7h4)kT5k%u`hbZ@I=Zc_WxPzWR-HbMN zukI4j8uSuL?+58hdJJ)Zo!+vqIsc;4}xNH=sE6PrWq~&(U>6YN(Qf_aMu5?K;S~ zLatXoH)^pmvojkj`c;P|$8ycaVG6Ev|bxE9i0bh~f4?)k*JmpP#m8te?N3ocOr@$wvk&3((I&PSVEj zj|3APSPzCnhHd#olyE*65@Hzben}ARwEAPa+s{rwCKIB!7l+cOY$y;2H`phZo?NB`zgz(Qx-2fw!z}C6$|8&Or&Gn`QbkjFt2LQ+cfPe9U z=4Sq{E>MIJ?3VR)E^-N%jHL)um#FYZ@z9&+lX$;t0S*8KK;D`wSuN7I69DMUup*{7 zT9XOPUnd~7L6D>ZWInT5;&1GF@gWWMt>&Dvgl_sX2|1!6Ky2_K+uUxP`do# z$kqfaF9D_j1;Yz>fBg-Ar~M3un78CZgPit0PHU1O=+osAKKTya!;jPKZ0NXT^`=HO zVT_~+Rhy*=J4`t$gGbZdPW-)!n5^p*Sm&NGyf8hc#uH+W?l$EyFnKnzA1Np##O=GS z^<>EE=Hvgwg^PdC@n3P_A9N%Ls*R6yCnb2I6*BTM1+EZYGW>Wsn8b#v##8Jb9lEN9V+yc9tN31Wg< z{V$^7q;TMtd}*k0e%%teMNZ&L;|B{O%ahYrp&kH?$aW;Ue?wu#YScd47GC_x=_9t= z3%0v8pI~N_#dM(+{3wNRqRHWLD%@n)TeIyC?AO1N&m{&28orFo!45=8NnP73>V3bH z1FlOEWM>;tK|HQ{P@Qc**$km;aYv{k^>F{r!W4)~^0Mm9ER%D=@+i9LY(m()-dbMt zxFbGH2Q>DUsp!WeaSG2pMa%L_eZlPdo~7;nUh~rLSsbB|;LMX&x_YYCTLw=yGV`?8 z<7D&KahW|{04zPcU7#f8;Khm;&vZ%%>C^2sgK7M~npbFzZSO}jrj~c)Dq@2AER@kyaVVfzwZ2Hp(>eL<955^Us} zzD?i;YQ{p$q<)sYFDCfyNYO$Gpc=YF$|FzWR)lplRo&PLdh~8|)UyrsXpniO>sP@9 z8SLw&jIqsAv4uToW9U}((qUy)0N{g$`nORu;yKNmuNJ2jougC_tsOv4VTynB|IccP z>9;R#`UU^H{yV}yy9(nR+AFq^hfGpz(ekhg-pD_!zjY*Ks6sz9A!l=sc=|u}|GQfH zI~i}f!vF3Wn4ghoB0+T!PA|Ehq@5KG0U?E@{bB<<5Zj#7h})P5mX&JirLj2 zjdLi^pNii1kw!!INEBuFoDUWW0|t-Q-5#UPD9KjtT5V3R=zby+FDQ$;E$&u#XV zSq2r82rK&a(AgY~On!m%r9P-Gof5QoIM>>~F0IpBQvTgwOri0Fdbm^`+eam@655{G zD8&$L$0QZN6bN)|bkg8eU6(;a6Qyipvwg4`@{3#O$sd%rbKTNG>Z?K-P|f_Ya-pNZUXI{b-WQQ|1s&*#3(uvwz;$5In|Dk`SoU{4AaN`c#Un3LpWn# zZ9A?ttW|Jy$dFUG^qtl?^ghlD-kSN?N0I&N2_fpU%$3m1Q>9+jK{!jTbudH)2Hhyx zxg9m9kb`%P+XykIItN~+@74s}?t!?v)Y4V?d?0nF@jFowNPai(kZ?!reJ8_Pvm6JV zowvp&pGYXdp`)!tbRQa19pVX^Pid11ej2DZ$78k##BO&#j9yp7ZHZg=k4xoYhnx@9 z>K@6onTdV^83H2Esc?Z@XYxr>W|&IbAnhpqR0J_e;O~lW4(t5X zY@IrqHbwGHjgUV#jjp_(tn<^bK1}oTc}oWzn1` { 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); + }); });