diff --git a/lib/image/avif.js b/lib/image/avif.js new file mode 100644 index 0000000..411db67 --- /dev/null +++ b/lib/image/avif.js @@ -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 diff --git a/test-data/expected/images/countryside.jpg b/test-data/expected/images/countryside.jpg new file mode 100644 index 0000000..51b36ec Binary files /dev/null and b/test-data/expected/images/countryside.jpg differ diff --git a/test-data/input/images/countryside.avif b/test-data/input/images/countryside.avif new file mode 100644 index 0000000..8f85f78 Binary files /dev/null and b/test-data/input/images/countryside.avif differ diff --git a/test/integration/image-avif.test.js b/test/integration/image-avif.test.js new file mode 100644 index 0000000..3feff60 --- /dev/null +++ b/test/integration/image-avif.test.js @@ -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) + }) +}) diff --git a/test/unit/avif.test.js b/test/unit/avif.test.js new file mode 100644 index 0000000..2a7e796 --- /dev/null +++ b/test/unit/avif.test.js @@ -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) +}