From 62b47cac1e0630dac0fdb0ff4da8ec14203db1e7 Mon Sep 17 00:00:00 2001 From: Alex Kocharin Date: Thu, 25 Feb 2021 18:21:43 +0300 Subject: [PATCH] Add support for AVIF file format --- .eslintrc.yml | 2 +- lib/parse_stream/avif.js | 147 +++++++++++++++++++++++++++++++++++ lib/parse_sync/avif.js | 126 ++++++++++++++++++++++++++++++ lib/parsers_stream.js | 1 + lib/parsers_sync.js | 3 +- test/fixtures/iojs_logo.avif | Bin 0 -> 1950 bytes test/test_formats.js | 20 +++++ 7 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 lib/parse_stream/avif.js create mode 100644 lib/parse_sync/avif.js create mode 100644 test/fixtures/iojs_logo.avif diff --git a/.eslintrc.yml b/.eslintrc.yml index 2c9e6cb..82e205c 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -40,7 +40,7 @@ rules: max-depth: [ 1, 6 ] max-nested-callbacks: [ 1, 4 ] # string can exceed 80 chars, but should not overflow github website :) - max-len: [ 2, 120, 1000 ] + max-len: [ 2, 120, 1000, { ignoreUrls: true } ] new-cap: 2 new-parens: 2 # Postponed diff --git a/lib/parse_stream/avif.js b/lib/parse_stream/avif.js new file mode 100644 index 0000000..d3661d1 --- /dev/null +++ b/lib/parse_stream/avif.js @@ -0,0 +1,147 @@ +'use strict'; + +/* eslint-disable consistent-return */ + + +var ParserStream = require('../common').ParserStream; +var str2arr = require('../common').str2arr; +var sliceEq = require('../common').sliceEq; +var readUInt32BE = require('../common').readUInt32BE; + +var SIG_FTYP = str2arr('ftyp'); + + +function readBox(data, offset) { + if (data.length < 4 + offset) return null; + + var size = readUInt32BE(data, offset); + + // size includes first 4 bytes (length) + if (data.length < size + offset || size < 8) return null; + + // if size === 1, real size is following uint64 (only for big boxes, not needed) + // if size === 0, real size is until the end of the file (only for big boxes, not needed) + + return { + type: String.fromCharCode.apply(null, data.slice(offset + 4, offset + 8)), + data: data.slice(offset + 8, offset + size), + end: offset + size + }; +} + + +function readAvifSizeFromMeta(data) { + var newData, box, offset; + + for (offset = 4 /* version+flags */, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'iprp') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'ipco') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'ispe') { + newData = box.data; + // no break here (!), second ispe may override the first, see + // https://github.com/tigranbs/test-heic-images + } + } + + if (!newData) return; + data = newData; + + return { + width: readUInt32BE(data, 4), + height: readUInt32BE(data, 8) + }; +} + + +function readAvifSize(parser, imgType) { + parser._bytes(8, function (data) { + var size = readUInt32BE(data, 0) - 8; + var type = String.fromCharCode.apply(null, data.slice(4, 8)); + + if (size <= 0) { + parser._skipBytes(Infinity); + parser.push(null); + return; + } else if (type === 'mdat') { + parser._skipBytes(Infinity); + parser.push(null); + return; + } else if (type === 'meta') { + parser._bytes(size, function (data) { + parser._skipBytes(Infinity); + + var imgSize = readAvifSizeFromMeta(data); + + if (!imgSize) return; + + parser.push({ + width: imgSize.width, + height: imgSize.height, + type: imgType === 'heic' ? 'heic' : 'avif', + mime: imgType === 'heic' ? 'image/heic' : 'image/avif', + wUnits: 'px', + hUnits: 'px' + }); + + parser.push(null); + }); + } else { + parser._skipBytes(size, function () { + readAvifSize(parser, type); + }); + } + }); +} + + +module.exports = function () { + var parser = new ParserStream(); + + parser._bytes(12, function (data) { + if (!sliceEq(data, 4, SIG_FTYP)) { + parser._skipBytes(Infinity); + parser.push(null); + return; + } + + var type = String.fromCharCode.apply(null, data.slice(8, 12)); + + if (type !== 'heic' && type !== 'avif') { + parser._skipBytes(Infinity); + parser.push(null); + return; + } + + var size = readUInt32BE(data, 0) - 12; + + if (size <= 0) { + parser._skipBytes(Infinity); + parser.push(null); + return; + } + + parser._skipBytes(size, function () { + readAvifSize(parser, type); + }); + }); + + return parser; +}; diff --git a/lib/parse_sync/avif.js b/lib/parse_sync/avif.js new file mode 100644 index 0000000..6589d73 --- /dev/null +++ b/lib/parse_sync/avif.js @@ -0,0 +1,126 @@ +// ISO media file spec: +// https://web.archive.org/web/20180219054429/http://l.web.umkc.edu/lizhu/teaching/2016sp.video-communication/ref/mp4.pdf +// +// ISO image file format spec: +// https://standards.iso.org/ittf/PubliclyAvailableStandards/c066067_ISO_IEC_23008-12_2017.zip +// + +/* eslint-disable consistent-return */ + +'use strict'; + + +var str2arr = require('../common').str2arr; +var sliceEq = require('../common').sliceEq; +var readUInt32BE = require('../common').readUInt32BE; + +var SIG_FTYP = str2arr('ftyp'); + + +/* + * interface Box { + * size: uint32; // if size == 0, box lasts until EOF + * boxtype: char[4]; + * largesize?: uint64; // only if size == 1 + * usertype?: char[16]; // only if boxtype == 'uuid' + * } + */ +function readBox(data, offset) { + if (data.length < 4 + offset) return null; + + var size = readUInt32BE(data, offset); + + // size includes first 4 bytes (length) + if (data.length < size + offset || size < 8) return null; + + // if size === 1, real size is following uint64 (only for big boxes, not needed) + // if size === 0, real size is until the end of the file (only for big boxes, not needed) + + return { + type: String.fromCharCode.apply(null, data.slice(offset + 4, offset + 8)), + data: data.slice(offset + 8, offset + size), + end: offset + size + }; +} + + +function readAvifSizeFromMeta(data) { + var newData, box, offset; + + for (offset = 4 /* version+flags */, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'iprp') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'ipco') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + if (box.type === 'ispe') { + newData = box.data; + // no break here (!), second ispe may override the first, see + // https://github.com/tigranbs/test-heic-images + } + } + + if (!newData) return; + data = newData; + + return { + width: readUInt32BE(data, 4), + height: readUInt32BE(data, 8) + }; +} + + +module.exports = function (data) { + // ISO media file (avif format) starts with ftyp box: + // 0000 0020 6674 7970 6176 6966 + // (length) f t y p a v i f + // + if (!sliceEq(data, 4, SIG_FTYP)) return; + + var type = String.fromCharCode.apply(null, data.slice(8, 12)); + + if (type !== 'avif' && type !== 'heic') return; + + var newData, box, offset; + + for (offset = 0, newData = null; (box = readBox(data, offset)); offset = box.end) { + // mdat block SHOULD be last (but not strictly required), + // so it's unlikely that metadata is after it + if (box.type === 'mdat') return; + if (box.type === 'meta') { + newData = box.data; + break; + } + } + + if (!newData) return; + data = newData; + + var imgSize = readAvifSizeFromMeta(data); + + if (!imgSize) return; + + return { + width: imgSize.width, + height: imgSize.height, + type: type === 'heic' ? 'heic' : 'avif', + mime: type === 'heic' ? 'image/heic' : 'image/avif', + wUnits: 'px', + hUnits: 'px' + }; +}; diff --git a/lib/parsers_stream.js b/lib/parsers_stream.js index 66ac5b8..a54b352 100644 --- a/lib/parsers_stream.js +++ b/lib/parsers_stream.js @@ -1,6 +1,7 @@ 'use strict'; module.exports = { + avif: require('./parse_stream/avif'), bmp: require('./parse_stream/bmp'), gif: require('./parse_stream/gif'), ico: require('./parse_stream/ico'), diff --git a/lib/parsers_sync.js b/lib/parsers_sync.js index 2641e49..c02edd6 100644 --- a/lib/parsers_sync.js +++ b/lib/parsers_sync.js @@ -2,10 +2,11 @@ module.exports = { + avif: require('./parse_sync/avif'), bmp: require('./parse_sync/bmp'), gif: require('./parse_sync/gif'), - jpeg: require('./parse_sync/jpeg'), ico: require('./parse_sync/ico'), + jpeg: require('./parse_sync/jpeg'), png: require('./parse_sync/png'), psd: require('./parse_sync/psd'), svg: require('./parse_sync/svg'), diff --git a/test/fixtures/iojs_logo.avif b/test/fixtures/iojs_logo.avif new file mode 100644 index 0000000000000000000000000000000000000000..b3f3757cf6d9ded89d838e5a2715433f4919caf1 GIT binary patch literal 1950 zcmXv}3p~?nAOANq)#S2Jx#W`G!oh|_Ix^diyUjJLq_uGx8#9y3jzp6})J{XDyRlS#BGJP0d`1wyH@{?0Hj2s z4w1>h-v@qol_kO-w4IV<376s9;{iOGMyE;8icBMhNN_JPghCG{QX&D6q#CL5A10H*uhOknbXT`c3xW$fFNw*A5-DJ>yu z-nBpFs8ZE1f2#!uTbS_q+ZrsD9Ish%9#>}|Y%nM44270;>$NKikaMierUanC=M>0s zQx2ndBYv>OTCBikg>y1X#p11B2fP-XS-YA(E?Vw6C4OFU%;)jFb35_nqK8nmJZ!qN z2GH|sqFfRWe^h@RJmTNy^1`I*Hg4mq-h0R*(u{ekg9Ppl^a2sWxySq|FXKl+5jD>- zccqPF3yLbWOKdJIM94RrO;T`Eu93J%47G4EekEMp+ehj_z<JRgYBI9j)gGt*0#p8t$9enkY<}8`_lvT;pe_#*kSN2nhIU z!EiGAO19N>8}lZ;ltp9vAW~@((n7o{n9k7J=t0BmpCqh>TzGovn63jGcIRSVoie01 z*43?gQEl)(ChK6*=`30Lqrb*|KJ%m!qoJ=mQ#wUAYUb*EU@LrcnH81x^a;_^gZC`D zQDYenKbh@$?R^o%aX-yJTR_HV>Iac+r%R2l+);-IQlWfVALNG9(#&+(YgVKI7~%C= z5og#68ddH*yG8BVHOh}tIqV^A0Pkgp)f*>1XPUj**jLxCv}hQn*hqh7(4p%DCX3kn z8wt6LWS<6hbk1=$dpdK)89RoRKAX=b-Z;IM@BC7~H3_0MTny7gPxOtt=EYaJ;raKz zpq@C=_bmwrbd0<@EEaE6Tcz1&=^Sr$(n}l0kE)hn)I21szo~b_y;}AAB>wib1lD*t zv3-#G(rF+Wk)~_3W&4egRH-bq(jEUvdA=`bSj)C6ilyQ2z4p`$ZVLlpotXldF|h^1 z2u{NM{`2*aInc5wuD)zB(~o;ZD3li_>IsrxKyDR9-NhargsIfZGG^<;6@nnQ0)cl8M}GR#MRI@ZNOx)$4KMP zX+<6aPmX6wsuFa%<_jj3gPJmbkcxPc{m{zvcxu=ABUv*iuO+(&)%cNBF%M^auomyj z@G)2}6iI+F4G@((Mlw#Xu#4o<&~pFgxE6Md6LG{Y_u%p9xKvkrCD|8NG^QX6OEVRV z1^l`th7)U2ki5ArvNZMJKx)Pn?8dj4*X0r)hhEp`AW^{;;2~0VtU4O5gL^Byeqk!~ z+CF+E<=h~9mY5gOscugg^DCGs`1qfvE>W%ZDtpusE)Ab4-b6Qh?H09TO|i$J2Ftl` zd>PGRUdz_ktYsDDAYE0X!xKD5%DDF>nnG)s&6%bOXmjon5X=6B?!w!=NpNA~2*bMm zeBe-|#WfN}UVZ^o_Z$C2zXPKx&h5#$7QI9b{<62>I67AUUaWdEMNu|<7p@_b9FSAp zypxvvN%h*%wyz&jqhcJr5-|Uaoi1Orw6<=Oj!Vwy^Xe`RUv`&qI=(@p5%c0#IyoPN zUXZWzRQkJ>PN3a459X2ghwVHmyMofa8RPc0MfQ+sWt_GHet0ig7L<0&9r1?49ek3W zGa}CmFhClj722d8#!%?z{ePJ~^NfD#L^u<^)>Kg~*U+VzeP;DGaXQYV^K7SyW~{CC zaHH~4g-}%N(90l{z>}UKo5P`jy$7K(1s4pY42!&j57cmTHa*oE9H&l=kqisEj3#1i+U2HK>7RP P5hb&}DbpG6M)>~)AvAE@ literal 0 HcmV?d00001 diff --git a/test/test_formats.js b/test/test_formats.js index a148797..a5cf432 100644 --- a/test/test_formats.js +++ b/test/test_formats.js @@ -304,6 +304,26 @@ describe('File formats', function () { }); + describe('AVIF', function () { + it('should detect AVIF', async function () { + let file = path.join(__dirname, 'fixtures', 'iojs_logo.avif'); + let size = await probe(fs.createReadStream(file)); + + assert.deepStrictEqual(size, { width: 367, height: 187, type: 'avif', mime: 'image/avif', wUnits: 'px', hUnits: 'px' }); + }); + }); + + + describe('AVIF (sync)', function () { + it('should detect AVIF', function () { + let file = path.join(__dirname, 'fixtures', 'iojs_logo.avif'); + let size = probe.sync(fs.readFileSync(file)); + + assert.deepStrictEqual(size, { width: 367, height: 187, type: 'avif', mime: 'image/avif', wUnits: 'px', hUnits: 'px' }); + }); + }); + + describe('PSD', function () { it('should detect PSD', async function () { let file = path.join(__dirname, 'fixtures', 'empty.psd');