├── .brackets.json ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── README.md ├── index.js ├── lib.js ├── package.json ├── scripts ├── harness.js └── images.js └── test └── index.test.js /.brackets.json: -------------------------------------------------------------------------------- 1 | { 2 | "spaceUnits": 2, 3 | "useTabChar": false, 4 | "language": { 5 | "javascript": { 6 | "linting.prefer": [ 7 | "ESLint" 8 | ], 9 | "linting.usePreferredOnly": true 10 | }, 11 | "markdown": { 12 | "wordWrap": true 13 | } 14 | }, 15 | "language.fileNames": { 16 | "Jenkinsfile": "groovy" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "es6": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 8 9 | }, 10 | "rules": { 11 | "indent": ["error", 2, { 12 | "SwitchCase": 1 13 | }], 14 | "linebreak-style": ["error", "unix"], 15 | "quotes": ["error", "single"], 16 | "semi": ["error", "always"] 17 | } 18 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | *.js text eol=lf 5 | *.jsx text eol=lf 6 | *.mjs text eol=lf 7 | *.json text eol=lf 8 | *.html text eol=lf 9 | *.md text eol=lf 10 | *.yml text eol=lf 11 | *.css text eol=lf 12 | *.less text eol=lf 13 | *.scss text eol=lf 14 | *.sass text eol=lf 15 | *.svg text eol=lf 16 | *.xml text eol=lf 17 | *.sh text eol=lf 18 | 19 | # Custom for Visual Studio 20 | *.cs diff=csharp 21 | 22 | # Standard to msysgit 23 | *.doc diff=astextplain 24 | *.DOC diff=astextplain 25 | *.docx diff=astextplain 26 | *.DOCX diff=astextplain 27 | *.dot diff=astextplain 28 | *.DOT diff=astextplain 29 | *.pdf diff=astextplain 30 | *.PDF diff=astextplain 31 | *.rtf diff=astextplain 32 | *.RTF diff=astextplain 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | env: 9 | FORCE_COLOR: 1 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | name: test with node ${{ matrix.node-version }} 15 | strategy: 16 | matrix: 17 | node-version: [12, 14, 16, 18, 20, 22] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm install 24 | - run: npm test 25 | - name: Inspect tarball 26 | run: npm pack --dry-run 27 | 28 | publish: 29 | needs: test 30 | runs-on: ubuntu-latest 31 | if: startsWith(github.ref, 'refs/tags/') && github.event_name != 'pull_request' 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: actions/setup-node@v3 35 | with: 36 | node-version: 16 37 | registry-url: https://registry.npmjs.org/ 38 | - run: npm publish --loglevel verbose 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | Desktop.ini 5 | 6 | # Recycle Bin used on file shares 7 | $RECYCLE.BIN/ 8 | 9 | # Windows shortcuts 10 | *.lnk 11 | 12 | # OSX 13 | .DS_Store 14 | .AppleDouble 15 | .LSOverride 16 | 17 | # Thumbnails 18 | ._* 19 | 20 | # Files that might appear in the root of a volume 21 | .DocumentRevisions-V100 22 | .fseventsd 23 | .Spotlight-V100 24 | .TemporaryItems 25 | .Trashes 26 | .VolumeIcon.icns 27 | 28 | # Directories potentially created on remote AFP share 29 | .AppleDB 30 | .AppleDesktop 31 | Network Trash Folder 32 | Temporary Items 33 | .apdisk 34 | 35 | # Node stuff 36 | node_modules/ 37 | coverage/ 38 | .nyc_output/ 39 | temp/ 40 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | Desktop.ini 5 | 6 | # Recycle Bin used on file shares 7 | $RECYCLE.BIN/ 8 | 9 | # Windows shortcuts 10 | *.lnk 11 | 12 | # OSX 13 | .DS_Store 14 | .AppleDouble 15 | .LSOverride 16 | 17 | # Thumbnails 18 | ._* 19 | 20 | # Files that might appear in the root of a volume 21 | .DocumentRevisions-V100 22 | .fseventsd 23 | .Spotlight-V100 24 | .TemporaryItems 25 | .Trashes 26 | .VolumeIcon.icns 27 | 28 | # Directories potentially created on remote AFP share 29 | .AppleDB 30 | .AppleDesktop 31 | Network Trash Folder 32 | Temporary Items 33 | .apdisk 34 | 35 | # Node stuff 36 | node_modules/ 37 | coverage/ 38 | .nyc_output/ 39 | temp/ 40 | 41 | # not production module code 42 | scripts/ 43 | temp/ 44 | test/ 45 | .* 46 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # heic-decode 2 | 3 | > Decode HEIC images to extract raw pixel data. 4 | 5 | [![CI][ci.svg]][ci.link] 6 | [![npm-downloads][npm-downloads.svg]][npm.link] 7 | [![npm-version][npm-version.svg]][npm.link] 8 | 9 | [ci.svg]: https://github.com/catdad-experiments/heic-decode/actions/workflows/ci.yml/badge.svg 10 | [ci.link]: https://github.com/catdad-experiments/heic-decode/actions/workflows/ci.yml 11 | [npm-downloads.svg]: https://img.shields.io/npm/dm/heic-decode.svg 12 | [npm.link]: https://www.npmjs.com/package/heic-decode 13 | [npm-version.svg]: https://img.shields.io/npm/v/heic-decode.svg 14 | 15 | ## Install 16 | 17 | ```bash 18 | npm install heic-decode 19 | ``` 20 | 21 | ## Usage 22 | 23 | Decode the main image in the file: 24 | 25 | ```javascript 26 | const fs = require('fs'); 27 | const { promisify } = require('util'); 28 | const decode = require('heic-decode'); 29 | 30 | (async () => { 31 | const buffer = await promisify(fs.readFile)('/path/to/my/image.heic'); 32 | const { 33 | width, // integer width of the image 34 | height, // integer height of the image 35 | data // Uint8ClampedArray containing pixel data 36 | } = await decode({ buffer }); 37 | })(); 38 | ``` 39 | 40 | Decode all images in the file: 41 | 42 | ```javascript 43 | const fs = require('fs'); 44 | const { promisify } = require('util'); 45 | const decode = require('heic-decode'); 46 | 47 | (async () => { 48 | const buffer = await promisify(fs.readFile)('/path/to/my/multi-image.heic'); 49 | const images = await decode.all({ buffer }); 50 | 51 | for (let image of images) { 52 | // decode and use each image individually 53 | // so you don't run out of memory 54 | const { 55 | width, // integer width of the image 56 | height, // integer height of the image 57 | data // Uint8ClampedArray containing pixel data 58 | } = await image.decode(); 59 | } 60 | 61 | // when you are done, make sure to free all memory used to convert the images 62 | images.dispose(); 63 | })(); 64 | ``` 65 | 66 | > Note: when decoding a single image (i.e. using `decode`), all resources are freed automatically after the conversion. However, when decoding all images in a file (i.e. using `decode.all`), you can decode the images at any time, so there is no safe time for the library to free resources -- you need to make sure to call `dispose` once you are done. 67 | 68 | When the images are decoded, the return value is a plain object in the format of [`ImageData`](https://developer.mozilla.org/en-US/docs/Web/API/ImageData). You can use this object to integrate with other imaging libraries for processing. 69 | 70 | _Note that while the decoder returns a Promise, it does the majority of the work synchronously, so you should consider using a worker thread in order to not block the main thread in highly concurrent production environments._ 71 | 72 | ## Related 73 | 74 | * [heic-cli](https://github.com/catdad-experiments/heic-cli) - convert heic/heif images to jpeg or png from the command line 75 | * [heic-convert](https://github.com/catdad-experiments/heic-convert) - convert heic/heif images to jpeg and png 76 | * [libheif-js](https://github.com/catdad-experiments/libheif-js) - libheif as a pure-javascript npm module 77 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const libheif = require('libheif-js/wasm-bundle'); 2 | 3 | const { one, all } = require('./lib.js')(libheif); 4 | 5 | module.exports = one; 6 | module.exports.all = all; 7 | -------------------------------------------------------------------------------- /lib.js: -------------------------------------------------------------------------------- 1 | const uint8ArrayUtf8ByteString = (array, start, end) => { 2 | return String.fromCharCode(...array.slice(start, end)); 3 | }; 4 | 5 | // brands explained: https://github.com/strukturag/libheif/issues/83 6 | // code adapted from: https://github.com/sindresorhus/file-type/blob/6f901bd82b849a85ca4ddba9c9a4baacece63d31/core.js#L428-L438 7 | const isHeic = (buffer) => { 8 | const brandMajor = uint8ArrayUtf8ByteString(buffer, 8, 12).replace('\0', ' ').trim(); 9 | 10 | switch (brandMajor) { 11 | case 'mif1': 12 | return true; // {ext: 'heic', mime: 'image/heif'}; 13 | case 'msf1': 14 | return true; // {ext: 'heic', mime: 'image/heif-sequence'}; 15 | case 'heic': 16 | case 'heix': 17 | return true; // {ext: 'heic', mime: 'image/heic'}; 18 | case 'hevc': 19 | case 'hevx': 20 | return true; // {ext: 'heic', mime: 'image/heic-sequence'}; 21 | } 22 | 23 | return false; 24 | }; 25 | 26 | const decodeImage = async (image) => { 27 | const width = image.get_width(); 28 | const height = image.get_height(); 29 | 30 | const { data } = await new Promise((resolve, reject) => { 31 | image.display({ data: new Uint8ClampedArray(width*height*4), width, height }, (displayData) => { 32 | if (!displayData) { 33 | return reject(new Error('HEIF processing error')); 34 | } 35 | 36 | resolve(displayData); 37 | }); 38 | }); 39 | 40 | return { width, height, data }; 41 | }; 42 | 43 | module.exports = libheif => { 44 | const decodeBuffer = async ({ buffer, all }) => { 45 | if (!isHeic(buffer)) { 46 | throw new TypeError('input buffer is not a HEIC image'); 47 | } 48 | 49 | // wait for module to be initialized 50 | // currently it is synchronous but it might be async in the future 51 | await libheif.ready; 52 | 53 | const decoder = new libheif.HeifDecoder(); 54 | const data = decoder.decode(buffer); 55 | 56 | const dispose = () => { 57 | for (const image of data) { 58 | image.free(); 59 | } 60 | 61 | decoder.decoder.delete(); 62 | }; 63 | 64 | if (!data.length) { 65 | throw new Error('HEIF image not found'); 66 | } 67 | 68 | if (!all) { 69 | try { 70 | return await decodeImage(data[0]); 71 | } finally { 72 | dispose(); 73 | } 74 | } 75 | 76 | return Object.defineProperty(data.map(image => { 77 | return { 78 | width: image.get_width(), 79 | height: image.get_height(), 80 | decode: async () => await decodeImage(image) 81 | }; 82 | }), 'dispose', { 83 | enumerable: false, 84 | configurable: false, 85 | writable: false, 86 | value: dispose 87 | }); 88 | }; 89 | 90 | return { 91 | one: async ({ buffer }) => await decodeBuffer({ buffer, all: false }), 92 | all: async ({ buffer }) => await decodeBuffer({ buffer, all: true }) 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "heic-decode", 3 | "version": "2.1.0", 4 | "description": "Decode HEIC images to raw data", 5 | "main": "index.js", 6 | "scripts": { 7 | "pretest": "node scripts/images.js", 8 | "test": "mocha \"test/**/*.js\" --timeout 20000 --slow 0" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/catdad-experiments/heic-decode.git" 13 | }, 14 | "keywords": [ 15 | "heic", 16 | "heif", 17 | "image", 18 | "photo", 19 | "decode" 20 | ], 21 | "author": "Kiril Vatev ", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/catdad-experiments/heic-decode/issues" 25 | }, 26 | "homepage": "https://github.com/catdad-experiments/heic-decode#readme", 27 | "dependencies": { 28 | "libheif-js": "^1.19.8" 29 | }, 30 | "devDependencies": { 31 | "buffer-to-uint8array": "^1.1.0", 32 | "chai": "^4.2.0", 33 | "eslint": "^5.16.0", 34 | "fs-extra": "^8.1.0", 35 | "jimp": "^0.9.3", 36 | "mocha": "^7.0.0", 37 | "node-fetch": "^2.6.0", 38 | "pixelmatch": "^5.2.1", 39 | "pngjs": "^5.0.0", 40 | "rootrequire": "^1.0.0" 41 | }, 42 | "engines": { 43 | "node": ">=8.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /scripts/harness.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { promisify } = require('util'); 3 | const fs = require('fs'); 4 | const Jimp = require('jimp'); 5 | const decode = require('../'); 6 | 7 | const readStdin = () => new Promise(resolve => { 8 | const result = []; 9 | 10 | process.stdin.on('readable', () => { 11 | let chunk; 12 | 13 | while ((chunk = process.stdin.read())) { 14 | result.push(chunk); 15 | } 16 | }); 17 | 18 | process.stdin.on('end', () => { 19 | resolve(Buffer.concat(result)); 20 | }); 21 | }); 22 | 23 | const createImage = promisify(({ data, width, height }, callback) => { 24 | try { 25 | new Jimp({ data: Buffer.from(data), width, height }, callback); 26 | } catch (e) { 27 | callback(e); 28 | } 29 | }); 30 | 31 | const toJpeg = async ({ data, width, height }) => { 32 | const image = await createImage({ data, width, height }); 33 | return image.quality(100).getBufferAsync(Jimp.MIME_JPEG); 34 | }; 35 | 36 | (async () => { 37 | const buffer = await readStdin(); 38 | const images = await decode.all({ buffer }); 39 | 40 | console.log('found %s images', images.length); 41 | 42 | for (let i in images) { 43 | console.log('decoding image', +i + 1); 44 | const image = images[i]; 45 | fs.writeFileSync(`./result-${+i + 1}.jpg`, await toJpeg(await image.decode())); 46 | } 47 | })().catch(err => { 48 | console.error(err); 49 | process.exitCode = 1; 50 | }); 51 | -------------------------------------------------------------------------------- /scripts/images.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const path = require('path'); 3 | const root = require('rootrequire'); 4 | const fs = require('fs-extra'); 5 | const fetch = require('node-fetch'); 6 | 7 | const resolve = (name = '') => path.resolve(root, 'temp', name); 8 | const drive = id => `http://drive.google.com/uc?export=view&id=${id}`; 9 | 10 | const images = [{ 11 | name: '0001.jpg', 12 | url: drive('1Mdlwd9i4i4HuVJjEcelUj6b0OAYkQHEj') 13 | }, { 14 | name: '0002.heic', // single image 15 | url: drive('1J_761fe_HWSijAthq7h_D2Zsf1_es1cT') 16 | }, { 17 | name: '0002-control.png', // single image control 18 | url: drive('1uomSNTAK5FifvI72lYi6T42zhvVv1LwH') 19 | }, { 20 | name: '0003.heic', // multiple images 21 | url: drive('1Iru2g084yZGz-eRagiqZgccRJZfGLYgS') 22 | }, { 23 | name: '0003-0-control.png', // multiple images control, index 0 24 | url: drive('1FZ9mJVj54MpD4c5TMfiOrarzkrT3pr2U') 25 | }, { 26 | name: '0003-1-control.png', // multiple images control, index 1, 2 27 | url: drive('1qD4-V6FcU2ffpNm0ZxKO2cpqbol73tok') 28 | }].map(img => { 29 | img.path = resolve(img.name); 30 | return img; 31 | }); 32 | 33 | (async () => { 34 | for (let image of images) { 35 | // we won't assume malicious intent... if the images exist, don't bother fetching them 36 | if (await fs.exists(image.path)) { 37 | continue; 38 | } 39 | 40 | const { url, name } = image; 41 | const res = await fetch(url); 42 | 43 | if (!res.ok) { 44 | throw new Error(`failed to download ${name} at ${url}: ${res.status} ${res.statusText}`); 45 | } 46 | 47 | const buffer = await res.buffer(); 48 | await fs.outputFile(image.path, buffer); 49 | } 50 | })().then(() => { 51 | console.log('all images fetch'); 52 | }).catch(err => { 53 | console.error('failed to fetch all images\n', err); 54 | process.exitCode = 1; 55 | }); 56 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | const { promisify } = require('util'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const root = require('rootrequire'); 7 | const { expect } = require('chai'); 8 | const { PNG } = require('pngjs'); 9 | const toUint8 = require('buffer-to-uint8array'); 10 | const pixelmatch = require('pixelmatch'); 11 | 12 | const readFile = promisify(fs.readFile); 13 | 14 | describe('heic-decode (default wasm bundle)', () => { 15 | runTests(require(root)); 16 | }); 17 | 18 | describe('heic-decode (js)', () => { 19 | const libheif = require('libheif-js'); 20 | const { one, all } = require('../lib')(libheif); 21 | const decode = one; 22 | decode.all = all; 23 | 24 | runTests(decode); 25 | }); 26 | 27 | function runTests(decode) { 28 | const readControl = async name => { 29 | const buffer = await readFile(path.resolve(root, `temp/${name}`)); 30 | const { data, width, height } = PNG.sync.read(buffer); 31 | 32 | return { data, width, height }; 33 | }; 34 | 35 | const compare = (expected, actual, width, height, errString = 'actual image did not match control image') => { 36 | const result = pixelmatch(toUint8(Buffer.from(expected)), toUint8(Buffer.from(actual)), null, width, height, { 37 | threshold: 0.1 38 | }); 39 | 40 | // allow 5% of pixels to be different 41 | expect(result).to.be.below(width * height * 0.05, errString); 42 | }; 43 | 44 | it('exports a function', () => { 45 | expect(decode).to.be.a('function'); 46 | expect(decode).to.have.property('all').and.to.be.a('function'); 47 | }); 48 | 49 | it('can decode a known image', async () => { 50 | const control = await readControl('0002-control.png'); 51 | const buffer = await readFile(path.resolve(root, 'temp', '0002.heic')); 52 | const { width, height, data } = await decode({ buffer }); 53 | 54 | expect(width).to.equal(control.width); 55 | expect(height).to.equal(control.height); 56 | expect(data).to.be.instanceof(Uint8ClampedArray); 57 | 58 | compare(control.data, data, control.width, control.height); 59 | }); 60 | 61 | it('can decode multiple images inside a single file', async () => { 62 | const buffer = await readFile(path.resolve(root, 'temp', '0003.heic')); 63 | const images = await decode.all({ buffer }); 64 | 65 | expect(images).to.have.lengthOf(3); 66 | expect(images).to.have.property('dispose').and.to.be.a('function'); 67 | 68 | const controls = await Promise.all([ 69 | readControl('0003-0-control.png'), 70 | readControl('0003-1-control.png') 71 | ]); 72 | 73 | for (let { i, control } of [ 74 | { i: 0, control: controls[0] }, 75 | { i: 1, control: controls[1] }, 76 | { i: 2, control: controls[1] }, 77 | ]) { 78 | expect(images[i]).to.have.property('decode').and.to.be.a('function'); 79 | expect(images[i]).to.have.property('width').and.to.equal(control.width); 80 | expect(images[i]).to.have.property('height').and.to.equal(control.height); 81 | 82 | const image = await images[i].decode(); 83 | 84 | expect(image).to.have.property('width', control.width); 85 | expect(image).to.have.property('height', control.height); 86 | expect(image).to.have.property('data').and.to.be.instanceOf(Uint8ClampedArray); 87 | 88 | compare(control.data, image.data, control.width, control.height, `actual image at index ${i} did not match control`); 89 | } 90 | 91 | images.dispose(); 92 | }); 93 | 94 | it('throws if data other than a HEIC image is passed in', async () => { 95 | const buffer = Buffer.from(Math.random().toString() + Math.random().toString()); 96 | 97 | try { 98 | await decode({ buffer }); 99 | throw new Error('decoding succeeded when it was expected to fail'); 100 | } catch (e) { 101 | expect(e).to.be.instanceof(TypeError) 102 | .and.to.have.property('message', 'input buffer is not a HEIC image'); 103 | } 104 | }); 105 | } 106 | --------------------------------------------------------------------------------