Skip to content

Commit

Permalink
Add support for animated WebP and GIF (via magick) (#2012)
Browse files Browse the repository at this point in the history
  • Loading branch information
deftomat authored Aug 17, 2020
1 parent 45653ca commit cb1baed
Show file tree
Hide file tree
Showing 12 changed files with 328 additions and 7 deletions.
64 changes: 63 additions & 1 deletion lib/output.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const formats = new Map([
['png', 'png'],
['raw', 'raw'],
['tiff', 'tiff'],
['webp', 'webp']
['webp', 'webp'],
['gif', 'gif']
]);

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -808,6 +869,7 @@ module.exports = function (Sharp) {
webp,
tiff,
heif,
gif,
raw,
tile,
// Private
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@
"Brendan Kennedy <brenwken@gmail.com>",
"Brychan Bennett-Odlum <git@brychan.io>",
"Edward Silverton <e.silverton@gmail.com>",
"Roman Malieiev <aromaleev@gmail.com>"
"Roman Malieiev <aromaleev@gmail.com>",
"Tomas Szabo <tomas.szabo@deftomat.com>"
],
"scripts": {
"install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node-gyp rebuild && node install/dll-copy)",
Expand Down
52 changes: 52 additions & 0 deletions src/common.cc
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ namespace sharp {
int32_t AttrAsInt32(Napi::Object obj, std::string attr) {
return obj.Get(attr).As<Napi::Number>().Int32Value();
}
int32_t AttrAsInt32(Napi::Object obj, unsigned int const attr) {
return obj.Get(attr).As<Napi::Number>().Int32Value();
}
double AttrAsDouble(Napi::Object obj, std::string attr) {
return obj.Get(attr).As<Napi::Number>().DoubleValue();
}
Expand All @@ -59,6 +62,14 @@ namespace sharp {
}
return rgba;
}
std::vector<int32_t> AttrAsInt32Vector(Napi::Object obj, std::string attr) {
Napi::Array array = obj.Get(attr).As<Napi::Array>();
std::vector<int32_t> 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) {
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -239,6 +253,7 @@ namespace sharp {
*/
bool ImageTypeSupportsPage(ImageType imageType) {
return
imageType == ImageType::WEBP ||
imageType == ImageType::MAGICK ||
imageType == ImageType::GIF ||
imageType == ImageType::TIFF ||
Expand Down Expand Up @@ -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<int> 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?
*/
Expand Down Expand Up @@ -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");
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<double> AttrAsRgba(Napi::Object obj, std::string attr);
std::vector<int32_t> AttrAsInt32Vector(Napi::Object obj, std::string attr);

// Create an InputDescriptor instance from a Napi::Object describing an input image
InputDescriptor* CreateInputDescriptor(Napi::Object input);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<int> delay, int loop);

/*
Does this image have a non-default density?
*/
Expand Down
55 changes: 50 additions & 5 deletions src/pipeline.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<char*>(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
Expand Down Expand Up @@ -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
Expand All @@ -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<char*>(baton->fileOut.data()), VImage::option()
Expand All @@ -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<char*>(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
Expand Down Expand Up @@ -1328,6 +1361,18 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
baton->heifCompression = static_cast<VipsForeignHeifCompression>(
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");
Expand Down
6 changes: 6 additions & 0 deletions src/pipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ struct PipelineBaton {
bool removeAlpha;
bool ensureAlpha;
VipsInterpretation colourspace;
int pageHeight;
std::vector<int> delay;
int loop;
int tileSize;
int tileOverlap;
VipsForeignDzContainer tileContainer;
Expand Down Expand Up @@ -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),
Expand Down
Binary file added test/fixtures/animated-loop-3.webp
Binary file not shown.
2 changes: 2 additions & 0 deletions test/fixtures/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added test/fixtures/rotating-squares.webp
Binary file not shown.
Loading

0 comments on commit cb1baed

Please sign in to comment.