Use AWS lambda for image processing

This is the toturial of how to do media file processing after uploaded to s3 by AWS lambda with node.

How it works

  1. Create an AWS Lambda, create a new lambda
  2. Create an IAM role with the buildin AWS policy, in my case, I named the role lambda_s3_writer with the default policy in the above image, the aws managed policy include read/write access to all S3 buckets and the permissions to write logs to cloudwatch.
  3. Add a lambda trigger, mine is just set when an object is uploaded into s3 bucket. lambda trigger
  4. There are 3 ways upload function code, uploading the whole code package into a zip file, and upload to one S4 bucket, then copy the S3 object link into the lambda configuration.
  5. You can also set Environment variables in lambda as well.

Functional Code

AWS lambda have imagemagick installed by default, so I can just use gm for image processing.

For extract file metadata, I recommend to use ffprobe, which can deal with image/video/audio files. However, in order to user ffprobe command in lambda, we will need to include this binary file in the package zip too. Luckily, we can use node module of ffprobe binary together with node module of ffprobe wrapper

npm install node-ffprobe --save

npm install ffprobe-static --save

index.js

'use strict';
var WIDTH = 1920;
var HEIGHT = 1080;
// get media file metadata
var probe = require('node-ffprobe');
var ffprobe = require('ffprobe-static');
probe.FFPROBE_PATH = ffprobe.path; // use the ffprobe-static's binary path
// resize image; fitScreen/bordering
var tools = require('./tools.js');
// POST to backend
var request = require('request');

var AWS = require('aws-sdk');
var s3 = new AWS.S3();

function waterfall(obj_params, callback) {
  ffprobe_meta(obj_params.Key)
    .then(meta => {
      console.log(`[need resize] ${need_resize(meta)}`);
      if(need_resize(meta)) {
        var info = {
          height: meta.streams[0].height,
          width: meta.streams[0].width,
          format: meta.streams[0].codec_name
        };
        return download(obj_params)
                 .then(s3object => resize(s3object, info))
                 .then(newObject => save(newObject, obj_params))
                 .then(() => ffprobe_meta(obj_params.Key))
      }
      else
        return Promise.resolve(meta);
    })
    .then(meta => {
      console.log('start posting...');
      request({
        url: process.env.SERVER_ORIGIN + process.env.END_POINT
        method: 'PATCH',
        json: true,   // required
        body: paramlise(meta, obj_params.Key)
      }, function (error, response){
        if (response.statusCode < 400) {
          console.info('Message posted successfully');
          callback(null);
        } else if (response.statusCode < 500) {
          console.error(`Error posting message to Slack API: ${response.statusCode} - ${response.statusMessage}`);
          callback(null);  // Don"t retry because the error is due to a problem with the request
        } else {
          // TODO: Let Lambda retry
          callback(`Server error when processing message: ${response.statusCode} - ${response.statusMessage}`);
        }
      });
    })
    .catch(err => console.log(err));
}

function ffprobe_meta(key) {
  console.log('[ffprobe_meta] trying to ffprobe...');
  return new Promise((resolve) => {
    probe(getpath(key), function(err, file_meta){
      if(err)
        console.log('[ffprobe_meta] ffprobe err:', err);
      else
        resolve(file_meta);
    });
  });
}
function download(obj_params){
  console.log(`[download] start downloading s3 object: ${obj_params.Key}`);
  return new Promise((resolve, reject) => {
    s3.getObject(obj_params, function(err, data){
      if(err) {
        console.log('[download] err:', err);
        reject(err);
      }
      else
        resolve(data);
    });
  });
}

// option = "bordering | stretch"
function resize(s3object, info) {
  console.log('[resize] trying to resize...');
  var option = s3object.Metadata['resize'];
  console.log(`[resize] option: ${option}`);
  if(option === 'stretch'){
    return tools.fitScreen(s3object.Body)
             .then(newBuffer => {
               return {
                 Body: newBuffer,
                 ContentDisposition: s3object.ContentDisposition,
                 Metadata: { 'resized': 'stretch' }
               }
             });
  }
  else if(option === 'bordering'){
    return tools.bordering(s3object.Body, info)
             .then(newBuffer => {
               return {
                 Body: newBuffer,
                 ContentDisposition: s3object.ContentDisposition,
                 Metadata: { 'resized': 'bordering' }
               }
             });
  }
  else {
    console.log('invalid option');
    return Promise.reject('invalid option');
  }
}

// replace the original s3 object with the resized image buffer and return the metadata of the new file
function save(newObject, obj_params){
  console.log('[save] replace the s3 object with resized one');
  newObject.Bucket = obj_params['Bucket'];
  newObject.Key = obj_params['Key'];
  return new Promise((resolve, reject) => {
    s3.putObject(newObject, function(err, data){
      if(err) {
        console.log(err);
        reject(err);
      }
      else {
        console.log('[save] image is successfully saved.');
        resolve(data);
      }
    });
  });
}

// ffprobe only work with path/url, cannot work with buffer
function getpath(key){
  return process.env.BUCKET_ORIGIN + '/' + key;
}

function paramlise(data, key) {
  var params = {};
  params.file_size = data.format.size;
  params.duration = data.format.duration;
  params.width = data.streams[0].width;
  params.height = data.streams[0].height;
  params.lambda_token = process.env.LAMBDA_TOKEN;
  params.location = key;
  return params;
}

// image with incorrect dimension will need resize
function need_resize(data) {
  if(data.streams[0].width === WIDTH && data.streams[0].height === HEIGHT)
    return false;
  var codec_name = data.streams[0].codec_name;
  if(codec_name === 'png' || codec_name === 'mjpeg' || codec_name === 'gif')
    return true;
  else
    return false;
}

// lambda entry
exports.handler = (event, context, callback) => {
  var obj_params = {
    Key: unescape(event.Records[0].s3.object.key),
    Bucket: unescape(event.Records[0].s3.bucket.name)
  };
  waterfall(obj_params, callback);
};

tools.js // a js lib to resize images

var gm = require('gm').subClass({ imageMagick: true });
var WIDTH = 1920;
var HEIGHT = 1080;
var RATIO = 16.0/9;

// dimension     ratio        scale          add bar
// 1920x1080     1.78         /              /
// 960x540       1.78         WIDTH/width    /
// 1920x1000     1.92         WIDTH/width    bottom
// 1800x1080     1.67         HEIGHT/height  right

var borderFigures = function(width, height) {
  var ratio = width * 1.0 / height;
  var percentage = ratio > RATIO ? WIDTH * 1.0 / width : HEIGHT * 1.0 / height;
  // force it an even integer, simpler to add two bars on two sides
  var scaled_width = parseInt(width * percentage) + parseInt(width * percentage) % 2;
  var scaled_height = parseInt(height * percentage) + parseInt(height * percentage) % 2;
  return {
    scaled_width: scaled_width,
    scaled_height: scaled_height,
    border_height: ratio > RATIO ? (HEIGHT-scaled_height)/2 : 0,
    border_width: ratio > RATIO ? 0 : (WIDTH-scaled_width)/2
  };
};

var getInfo = function(buffer) {
  return new Promise((resolve) => {
      gm(buffer)
        .identify({ bufferStream: true }, (err, meta) => {
          if(err) {
            console.log(`[bordering] gm err: ${err}`);
            reject(err);
          }
          else
            resolve({
              width: meta.size.width,
              height: meta.size.height,
              format: meta.format
            });
        });
  });
};

// meta: {width, height, format}
var resizeBuffer = function (buffer, meta) {
  return new Promise(resolve => {
    var vars = borderFigures(meta.width, meta.height);
    gm(buffer)
      .resizeExact(vars.scaled_width, vars.scaled_height)
      .borderColor(255)
      .border(vars.border_width, vars.border_height)
      .toBuffer(meta.format, (err, newBuffer) => resolve(newBuffer));
  });
}

module.exports = {
  settings: function(width, height) {
    return borderFigures(width, height);
  },

  bordering: function(buffer, info) {
    // if info: {width, height, format } is known, skip the getInfo promise
    return (info ? Promise.resolve(info) : getInfo(buffer))
           .then(info => resizeBuffer(buffer, info));
  },

  fitScreen: function(buffer) {
    return new Promise((resolve) => {
      gm(buffer)
        .resizeExact(WIDTH, HEIGHT)
        .toBuffer('png', (err, newBuffer) => resolve(newBuffer));
    });
  }
};

How to pack the whole code package

rm -rf node_modules

npm install

The ffprobe-statics includes the binary files for most Common OS, e.g. Windows, linux, MacOS, x64/x32, so the packed zip file is too large, as the aws lambda uses the linux(x64)

the command to pack:

zip $1 index.js tools.js -r node_modules/ -x node_modules/ffprobe-static/bin/darwin/\* -x node_modules/ffprobe-static/bin/win32/\* -x node_modules/ffprobe-static/bin/linux/ia32/\* >/dev/null

the command to put the zip to s3

s3cmd put deploy-1.0.0.zip s3://bucketname/lambda/deploy-1.0.0.zip

set the s3 object link to be the functional code link.