August 14th, 2016

Extracting EXIF Data with Node.js

Right before my daughter was born, I did what many proud-parents-to-be do and purchased a really nice camera with the hope of capturing all of the up-coming special moments. With the purchase, the sales people at Precision Camera highlighted the several classes they offered at the store and even threw in a free 'basics' class. I took them up on the offer because I was admittedly rusty behind the lens and I wanted to know all the ins and outs of my new camera. After taking the basics class, I immediately threw down cash for the intermediate classes. These classes ran for a full month and walked us through all the features of our modern DLSR cameras. By the end, I was comfortable taking pictures in manual (M) mode. Now, I get some pretty nice results every once in a while ;).

At one point during the class, the instructor explained a feature of the taken photograph known as metadata or EXIF (Exchangeable Image File Format). This is data saved in addition to the captured image when you take a picture and includes data points like: shutter speed, focal length, and aperture settings.

Seeing how this blog features photographs I've taken, I thought I'd surface the photographic metadata along side the image. Not only does it help me to visually diagnose the photo, but it also helps to add some proverbial 'bread-crumbs', leading readers in the direction of how the photo was made.

To extract the EXIF data from the image, I used a very handy piece of software called node-exif. The idea was to leverage this extractor to pull the data out and ultimately return a JSON object that listed file names as keys, and the corresponding EXIF data as values (e.g: {filename: exif_data}). Here's the Node.js implementation (expect changes, bug fixes, and further optimizations):

First, I imported some necessary libs and established some initial variables.

const ExifImage = require('exif').ExifImage;
const fs = require('fs');
const path = require('path');
const glob = require('glob');

let defaultOptions = {
  source: '../assets/images',
  filename: 'exif.json',
  target: '../assets/scripts'
};
let output = {'images': {}}; //this is the final returned object
let count = 0;
let complete = (err, response) => {
  if (err) throw Error(err);
  return response;
}

Next, I wrote the EXIF extractor, which looks at a directory of images, specifically .jpeg, and processes them using node-exif.

function getExif(options = defaultOptions, oncomplete = complete) {
  let { source, target } = options;
  if (!source) throw Error("Missing source directory parameter");
  source = source.slice(-1) == '/' ? source.slice(0, -1) : source;

  glob(`${source}/**/*.{jpg,jpeg,JPG,JPEG}`, {}, (err, files) => {
    files.forEach(filename => {
      new ExifImage({ image: filename }, (err, exifData) => {
        if (err) { return oncomplete(err, null); }
        filename = filename.split('/');
        filename = filename[filename.length - 1];
        output.images[filename] = exifData;
        count++;

        if (count >= files.length) {
          return handleCompletion(options, oncomplete);
        }
      });
    });
  });
}

You can see the code iterates over the matched files in the given directory and creates a key/value pair on the output object. Then, it checks to see if all the files have been processed before conditionally calling the handleCompletion method.

function handleCompletion(options, oncomplete) {
  let target = options.target;

  if (target) {
    let file = path.join(target, options.filename);
    let data = JSON.stringify(output, null, 2);

    fs.writeFile(file, data, { encoding: 'utf8' }, (err) => {
      if (err) throw err;
      oncomplete(null, `exif file written to ${target}`);
    });
  }
  return oncomplete(null, output);
}

When the code has completed processing all the images, it does one of two things. It either a) checks to see if a valid target option has been passed in on the options object, and conditionally writes the JSON to a file, or b) returns the output to the oncomplete callback. This added flexibility allows this code to play well with http://www.metalsmith.io/, the static site generator I'm using to create this site.

Finally, we export the getExif method so it can be called from Node.js, and used in conjunction with our build scripts or other Node.js programs.

module.exports = getExif;

Then, in order to get this working with metalsmith, I wrote a simple wrapper that ultimately extracts the EXIF, and applies the output to the metalsmith metadata so it can be used in my templates. Here's an example of the implementation:

const exifToJSON = require('./exifToJSON');
const path = require('path');

module.exports = function (options) {
  return function (files, metalsmith, done) {

    const { _directory } = metalsmith;
    let metadata = metalsmith.metadata();
    let { target, filename, source } = options;

    let config = {
      source: path.join(_directory, source),
      filename: 'exif.json'
    };

    if (target) {
      config.target = path.join(_directory, target);
      if (filename) {
        config.filename = filename
      }
    }

    if (!source) {
      console.log('Missing source directory for EXIF');
      return false;
    }

    exifToJSON(config, (err, data) => {
      metadata.exif = data;
      done();
    });
  }
}

That's it, really. Feel free to remix as you see fit. Like any code, there's room for improvement but, this is roughly what I use on this site to display date, aperture, shutter speed, ISO, focal length, and camera type. There are many other values you can use, but these are the more useful in my opinion.

If you have questions, ping me: jwaltonmedia[at]gmail[dot]com

Thanks!

2017 : James Walton : Digital Carpentry