inventid/iaas

View on GitHub
src/image.js

Summary

Maintainability
A
3 hrs
Test Coverage
import { MAX_IMAGE_ON_DISK} from "./sizes";

require("babel-polyfill");

import fs from "fs";
import gm from "gm";
import config from "config";
import log from "./log";
import uuid from "uuid/v4";
import * as fastCache from './fastCache';

const gmOptions = {};
if (config.has('timeout.conversion')) {
  const timeout = Number(config.get('timeout.conversion'));
  if (isNaN(timeout)) {
    log('warn', 'The configuration value of timeout.conversion resolved to a NaN value. Ignoring it!');
  } else if (timeout < 0) {
    log('warn', 'The configuration value of timeout.conversion did not resolve to a nonnegative value. Ignoring it!');
  } else if (timeout === 0) {
    log('info', 'Not setting any image timeout');
  } else {
    gmOptions.timeout = timeout;
  }
}

const options = Object.assign({}, {imageMagick: true}, gmOptions);
log('info', `Booting gm with the following options: ${JSON.stringify(options)}`);

const im = gm.subClass(options);

// 10 seconds
const CLEAR_TEMP_FILES_TIMEOUT = 10000;

// Wrap these calls in promises so we can use async/await
const write = (client, file) => {
  return new Promise((resolve, reject) => client.write(file, err => err ? reject(err) : resolve()));
};
const size = async (client) => {
  return await new Promise((resolve, reject) => client.size({bufferStream: true}, (err, data) => err ? reject(err) : resolve(data)));
};

// Strip the image of any profiles or comments
const strip = async (client) => {
  return client.strip();
};

// Interlacing for png isn't efficient (both in filesize as render performance), so we only do it for jpg
const interlace = async (client, params) => {
  if (params.mime === 'image/jpeg') {
    return client.interlace('Line');
  }
  return client;
};

// When cropping, we size the image first to fix completely within the bounding box
// Then we crop the requested size, and center to the center of the image
const crop = async (client, params) => {
  // Resize the image to fit within the bounding box
  // The ^ ensures the images is resized while maintaining the ratio
  // However the sizes are treated as minimum values instead of maximum values
  client = client.resize(params.width, params.height, '^');
  const tmpFile = `/tmp/${uuid()}`;
  try {
    await write(client, tmpFile);
    client = im(tmpFile).options(gmOptions);
    const imgSize = await size(client);
    setTimeout(() => fs.unlink(tmpFile, err => {
      if (err) {
        log('error', `Tempfile ${tmpFile} could not be deleted`);
      }
    }), CLEAR_TEMP_FILES_TIMEOUT);
    return client
      .repage(imgSize.width, imgSize.height, 0, 0)
      .gravity('Center')
      // Crop the image to the exact size (the ! indicates a force)
      // This is ok since we first resized appropriately
      .crop(params.width, params.height, '!');
  } catch (e) {
    log('error', 'could not write tempfile');
    log('error', e.stack);
    throw e;
  }
};

// The sizes in the params given define the bounding box. The image is sized
// proportionally such that it fits in the bounding box without cropping
const clip = async (client, params) => {
  return client.resize(params.width, params.height);
};

const cover = async (client, params) => {
  return client.resize(params.width, params.height, '^');
};

// Same as clipping, however the remainder of the bounding box is filled with
// white.
const canvas = async (client, params) => {
  client = await clip(client, params);
  return client.gravity('Center').extent(params.width, params.height);
};

// Fill a transparent background with white
const background = async (client, params) => {
  if (params.mime === 'image/jpeg') {
    return client.background('white').flatten();
  }
  return client;
};

// Fit the image appropriately.
const fit = async (client, params) => {
  //For original format
  if (!params.width || !params.height) {
    return client;
  }
  if (params.fit === 'crop') {
    return crop(client, params);
  } else if (params.fit === 'canvas') {
    return canvas(client, params);
  } else if (params.fit === 'clip') {
    return clip(client, params);
  } else if (params.fit === 'cover') {
    return cover(client, params);
  }
  throw new Error(`Format '${params.fit}' was accepted but could not be handled`);
};

const setQuality = async (client, params) => {
  if (params.quality === -1) {
    return client;
  }

  // The imagemagick 'quality' parameter has a very different meaning for
  // different image types, so it's not a good idea to just blindly pass it
  // through. For now, we only support jpg and webp compression, which is the most intuitive.
  switch (params.mime) {
    case 'image/jpeg':
    case 'image/webp':
      return client.quality(Math.min(100, Math.max(0, params.quality)));
    default:
      //No compression supported for other types
      return client;
  }
};

// Blur the image if requested in the params
const blur = async (client, params) => {
  if (params.blur === null) {
    return client;
  }
  return client.blur(params.blur.radius, params.blur.sigma);
};

export async function magic(file, params) {
  // We add the [0] so we always read the first frame. As such we do not support any animation (as intended)
  let client = im(`${file}[0]`).options(gmOptions);
  client = await strip(client);
  client = await fit(client, params);
  client = await background(client, params);
  client = await blur(client, params);
  client = await setQuality(client, params);
  client = await interlace(client, params);
  return client;
}

export async function imageSize(path) {
  // Check whether we have this thing in cache first
  const cacheResult = await fastCache.getSizeFromCache(path);
  if (cacheResult) {
    return cacheResult;
  }
  const result = await size(im(path).options(gmOptions));
  // Dont wait for adding it to the cache
  fastCache.addSizeToCache(path, result);
  return result;
}

export async function writeOriented(source, destination, cropParameters) {
  // if possible, crop first (since the UA had that orientation), then orient
  if (cropParameters) {
    const cropped = im(source).options(gmOptions)
      .crop(cropParameters.width, cropParameters.height, cropParameters.xOffset, cropParameters.yOffset);
    try {
      await write(cropped, source);
    } catch (e) {
      log('error', e.stack);
      throw e;
    }
  }

  const maxSizeAlongAxis = Math.floor(Math.sqrt(MAX_IMAGE_ON_DISK * 1e6));
  const oriented = im(source).options(gmOptions)
    .autoOrient()
    // We also reduce the saved size a bit, so the size on disk is a lot smaller, which is essential for future conversions
    // We'll only downscale, and maintain aspect ratios
    .resize(maxSizeAlongAxis, maxSizeAlongAxis, ">");

  try {
    await write(oriented, destination);
  } catch (e) {
    log('error', e.stack);
    throw e;
  }

  try {
    // Set this into the cache early
    const imgSize = await imageSize(destination);
    return {
      originalHeight: imgSize.height || null,
      originalWidth: imgSize.width || null
    };
  } catch (e) {
    log('error', e.stack);
    return {
      originalHeight: null,
      originalWidth: null
    };
  }
}

export async function imageArea(path) {
  try {
    const imgSize = await imageSize(path);
    return imgSize.width * imgSize.height;
  } catch (e) {
    log('error', e);
    return Number.MAX_SAFE_INTEGER;
  }
}