Skip to content

Commit

Permalink
Add {animated: true} flag to resize an animated GIF
Browse files Browse the repository at this point in the history
Adds new optional dependency on Gifsicle, which needs to be in the system path.
  • Loading branch information
rprieto committed Jun 6, 2018
1 parent 9191757 commit 8dabfce
Show file tree
Hide file tree
Showing 16 changed files with 135 additions and 40 deletions.
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ This is one of the core modules of [thumbsup.github.io](https://thumbsup.github.
npm install thumbsup-downsize --save
```

This module requires [GraphicsMagick](http://www.graphicsmagick.org/)
and [FFMpeg](https://ffmpeg.org/) on the target system, available in the system path.
This module requires the following binaries available in the system path:

- [GraphicsMagick](http://www.graphicsmagick.org/) for processing images
- [FFMpeg](https://ffmpeg.org/) for processing videos
- [Gifsicle](http://www.lcdf.org/gifsicle/) for processing animated GIFs

## Usage

Expand Down Expand Up @@ -94,14 +97,32 @@ You can specify extra arguments that will be passed to GraphicsMagick.
This only works with [output arguments](https://github.com/aheckmann/gm#custom-arguments).

```js
options = {
opts = {
args: [
'-unsharp 2 0.5 0.7 0',
'-modulate 120'
]
}
```

##### GIF animation

By default, only the first frame of an animated GIF is exported.
You can keep the entire animation by specifying:

```js
opts = { animated: true }
```

This offloads the processing of the image to [Gifsicle](https://github.com/kohler/gifsicle).
Note that:

- The destination file extension *must* be `.gif`
- The only other supported parameters are `width` and `height` (e.g. no watermarks)
- Cropping (specifying *both* width and height) is not supported and will throw an error

The flag is simply ignored if the source file is not a GIF.

### .still

```js
Expand Down
14 changes: 14 additions & 0 deletions lib/gifsicle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const childProcess = require('child_process')
const GIF_FILE = /\.gif$/i

exports.createAnimatedGif = (source, target, options, callback) => {
if (!target.match(GIF_FILE)) {
throw new Error(`Target should have the <gif> extension but was: ${target}`)
}
if (options.width && options.height) {
throw new Error(`Cannot crop a GIF image while keeping the animation: ${target}`)
}
const resize = `${options.width || '_'}x${options.height || '_'}`
const args = [ '-O2', '--resize-fit', resize, '-o', target, source ]
return childProcess.execFile('gifsicle', args, callback)
}
24 changes: 22 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,36 @@ const async = require('async')
const gm = require('gm')
const mkdirp = require('mkdirp')
const path = require('path')
const gifsicle = require('./gifsicle')
const gmargs = require('./gmargs')
const ffmpeg = require('./ffmpeg')

const GIF_FILE = /\.gif$/i

/*
Convert and/or resize an image
*/
exports.image = function (source, target, options, callback) {
// create target folder if needed
mkdirp.sync(path.dirname(target))
// read baked-in orientation info, and output a rotated image with orientation=0

// when processing a GIF
// - if asking for an animated target, process with Gifsicle
// - otherwise only process the first frame
if (source.match(GIF_FILE)) {
if (options.animated) {
return gifsicle.createAnimatedGif(source, target, options, callback)
} else {
source += '[0]'
}
}

// start processing with GraphicsMagick
const image = gm(source)

// read baked-in orientation info, and output a rotated image with orientation=0
image.autoOrient()

// optional watermark
const cropping = options.width && options.height
if (options.watermark && !cropping) {
Expand All @@ -26,8 +44,9 @@ exports.image = function (source, target, options, callback) {
image.gravity('SouthEast')
}
}

// resize if necessary
if (options.width && options.height) {
if (cropping) {
// crop to the exact height and weight
image.resize(options.width, options.height, '^')
image.gravity('Center')
Expand All @@ -40,6 +59,7 @@ exports.image = function (source, target, options, callback) {
// resize to a maximum width
image.resize(options.width, null, '>')
}

// default quality, for typical web-friendly sizes
image.quality(options.quality || 90)
// apply custom post-processing arguments (sharpen, brightness...)
Expand Down
17 changes: 9 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"split": "^1.0.1"
},
"devDependencies": {
"lodash": "^4.17.10",
"mock-spawn": "^0.2.6",
"require-lint": "^1.2.0",
"sinon": "^4.1.3",
Expand Down
Binary file removed test-data/expected/images/nyan-frame.large.gif
Binary file not shown.
Binary file removed test-data/expected/images/nyan-frame.thumb.gif
Binary file not shown.
Binary file added test-data/expected/images/simpsons.anim.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-data/expected/images/simpsons.cropped.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-data/expected/images/simpsons.resized.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-data/expected/images/toad.frame.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-data/expected/images/toad.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-data/input/images/simpsons.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-data/input/images/toad.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 37 additions & 5 deletions test/diff.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
const _ = require('lodash')
const childProcess = require('child_process')
const gm = require('gm')
const convert = require('../lib/index')

const TOLERANCE = { tolerance: 0.001 }

// process an image, and compare to the expected output
exports.image = function (test, args) {
const input = `test-data/input/${args.input}`
Expand Down Expand Up @@ -40,10 +40,42 @@ exports.video = function (test, args) {
}

function compareImage (test, expected, actual) {
gm.compare(expected, actual, TOLERANCE, (err, similar) => {
const isGif = actual.match(/\.gif$/i)
test.test('metadata', t => {
const fields = isGif ? ['AnimationIterations', 'FrameCount', 'Duration', 'ImageSize'] : ['ImageSize', 'Megapixels']
compareMetadata(t, expected, actual, fields)
})
test.test('visual', t => {
// We have to be less picky when comparing GIFs
// because Gifsicle produces slightly different results between versions
const tolerance = isGif ? 0.3 : 0.001
compareVisual(t, expected, actual, tolerance)
})
}

function compareMetadata (test, expected, actual, fields) {
try {
test.deepEqual(
_.pick(exiftool(actual), fields),
_.pick(exiftool(expected), fields),
`${actual} has same metadata as expected`
)
test.end()
} catch (ex) {
test.end(`exiftool failed to get metadata`)
}
}

function compareVisual (test, expected, actual, tolerance) {
gm.compare(expected, actual, tolerance, (err, similar, equality) => {
if (err) return test.end(err)
if (!similar) return test.end(`${expected} is different from expected`)
test.pass(`${expected} is as expected`)
if (!similar) return test.end(`${actual} is visually different from expected (equality=${equality})`)
test.pass(`${actual} is visually similar to expected`)
test.end()
})
}

function exiftool (imagePath) {
const output = childProcess.execSync(`exiftool -j ${imagePath}`)
return JSON.parse(output)[0]
}
50 changes: 28 additions & 22 deletions test/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,45 +25,51 @@ tape('can set a custom output quality', (test) => {
})
})

tape('crops a non-animated GIF', (test) => {
tape('create a cropped frame from an animated GIF', (test) => {
diff.image(test, {
input: 'images/countdown-frame.gif',
expect: 'images/countdown-frame.thumb.gif',
options: { height: 100, width: 100 }
input: 'images/simpsons.gif',
expect: 'images/simpsons.cropped.gif',
options: {
height: 150,
width: 150
}
})
})

tape('resizes a non-animated GIF proportionally', (test) => {
tape('creates a resized frame from an animated GIF', (test) => {
diff.image(test, {
input: 'images/countdown-frame.gif',
expect: 'images/countdown-frame.large.gif',
options: { height: 150 }
input: 'images/simpsons.gif',
expect: 'images/simpsons.resized.gif',
options: {
height: 150
}
})
})

tape('crops a transparent GIF', (test) => {
tape('creates a resized animated GIF', (test) => {
diff.image(test, {
input: 'images/nyan-frame.gif',
expect: 'images/nyan-frame.thumb.gif',
options: { height: 100, width: 100 }
input: 'images/simpsons.gif',
expect: 'images/simpsons.anim.gif',
options: {
height: 150,
animated: true
}
})
})

tape('resizes a transparent GIF', (test) => {
tape('extract a frame from a transparent animated GIF', (test) => {
diff.image(test, {
input: 'images/nyan-frame.gif',
expect: 'images/nyan-frame.large.gif',
options: { height: 150 }
input: 'images/toad.gif',
expect: 'images/toad.frame.gif',
options: { height: 100 }
})
})

// should be a single frame, a cropped animation wouldn't make much sense
// currently an issue, the thumbnail is all weird
tape.skip('crops an animated GIF to a single frame', (test) => {
tape('resizes a transparent animated GIF', (test) => {
diff.image(test, {
input: 'images/nyan.gif',
expect: 'images/nyan.thumb.gif',
options: { height: 100, width: 100 }
input: 'images/toad.gif',
expect: 'images/toad.gif',
options: { height: 100, animated: true }
})
})

Expand Down

0 comments on commit 8dabfce

Please sign in to comment.