Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions lib/image/avif.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const childProcess = require('node:child_process')
const path = require('node:path')
const async = require('async')
const tmp = require('tmp')
const trace = require('debug')('thumbsup:trace')

// IMPORTANT NOTE
//
// We rely on GraphicsMagick for all image processing, and ImageMagick 7 to convert AVIF to JPEG

const SRGB_ICM_PATH = path.join(__dirname, 'sRGB.icm')
const processed = {}

// This function is typically called several times, for the thumbnail, small version, large version...
// To avoid converting the image 3 times we re-use previously converted images
exports.convert = function (source, callback) {
if (!processed[source]) {
const tmpfile = tmp.fileSync({ postfix: '.jpg' })
processed[source] = processFile(source, tmpfile.name)
}
processed[source].then((target) => callback(null, target)).catch(err => callback(err))
}

// Return a promise so multiple callers can subscribe to when it's finished
function processFile (source, target) {
return async.series([
done => convertToJpeg(source, target, done),
done => copyColorProfile(source, target, done),
done => convertToSRGB(target, done)
]).then(() => target)
}

function convertToJpeg (source, target, done) {
// only process the first image, in case of burst shots
const args = ['convert', `${source}[0]`, target]
exec('magick', args, done)
}

function copyColorProfile (source, target, done) {
const args = ['-overwrite_original', '-TagsFromFile', source, '-icc_profile', target]
exec('exiftool', args, done)
}

function convertToSRGB (target, done) {
const args = ['mogrify', '-profile', SRGB_ICM_PATH, target]
exec('magick', args, done)
}

function exec (command, args, done) {
trace(command + ' ' + args.map(a => `"${a}"`).join(' '))
childProcess.execFile(command, args, done)
}

// Optionally, we could remove the original profile
// It shouldn't matter since we're resizing the image afterwards
// exiftool -overwrite_original "-icc_profile:all=" photo.jpg
Binary file added test-data/expected/images/countryside.jpg
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/countryside.avif
Binary file not shown.
13 changes: 13 additions & 0 deletions test/integration/image-avif.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const diff = require('./diff')

describe('image AVIF', () => {
it('can process a single-image AVIF file', done => {
diff.image({
input: 'images/countryside.avif',
expect: 'images/countryside.jpg',
options: {
height: 200
}
}, done)
})
})
65 changes: 65 additions & 0 deletions test/unit/avif.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const childProcess = require('node:child_process')
const should = require('should/as-function')
const async = require('async')
const sinon = require('sinon')
const avif = require('../../lib/image/avif')

afterEach(() => {
sinon.restore()
})

describe('avif', () => {
it('calls gmagick and exiftool', done => {
sinon.stub(childProcess, 'execFile').callsFake(fakeExecFile)
avif.convert('input1.avif', err => {
should(err).eql(null)
should(childProcess.execFile.callCount).eql(3)
should(childProcess.execFile.getCall(0).args[0]).eql('magick')
should(childProcess.execFile.getCall(1).args[0]).eql('exiftool')
should(childProcess.execFile.getCall(2).args[0]).eql('magick')
done()
})
})

it('stops at the first failing call', done => {
sinon.stub(childProcess, 'execFile').callsFake(fakeExecFileFail)
avif.convert('input2.avif', err => {
should(err.message).eql('FAIL')
should(childProcess.execFile.callCount).eql(1)
should(childProcess.execFile.getCall(0).args[0]).eql('magick')
done()
})
})

it('only processes each file once', done => {
sinon.stub(childProcess, 'execFile').callsFake(fakeExecFile)
async.parallel([
done => avif.convert('input3.avif', done),
done => avif.convert('input3.avif', done)
]).then(res => {
should(childProcess.execFile.callCount).eql(3)
done()
})
})

it('keeps track of files already processed', done => {
sinon.stub(childProcess, 'execFile').callsFake(fakeExecFile)
async.parallel([
done => avif.convert('input4.avif', done),
done => avif.convert('input5.avif', done),
done => avif.convert('input6.avif', done),
done => avif.convert('input4.avif', done)
]).then(res => {
should(childProcess.execFile.callCount).eql(3 * 3)
done()
})
})
})

function fakeExecFile (cmd, args, done) {
setTimeout(done, 50)
}

function fakeExecFileFail (cmd, args, done) {
setTimeout(() => done(new Error('FAIL')), 50)
}