Skip to main content

๐Ÿ–ผ๏ธ Image Processing

Summaryโ€‹

Goals
  1. To allow the user to upload any image.
  2. Resize once, compress and standardise the image saved.
  3. Public accessible bucket that is cached.
  4. An Image CDN performs transformations and delivers best format and size for device.

Image CDN Optionsโ€‹

  1. CloudImage.io

    25GB free tier, doesn't charge for transformations.

  2. Cloudflare image resizing

    As part of a paid tier, Cloudflare allows image resizing using their workers

  3. Cloudflare images

    One API to store, resize, optimize, and deliver images at scale. Store, resize, and optimize images at scale using one unified product.

  4. IMGProxy self hosted

    imgproxy.rentreef.com

  5. Google CloudRun docker container
  6. Firebase Cloud Functions resizer
// https://maxbarry.medium.com/dynamic-on-demand-image-resizing-using-firebase-hosting-and-google-cloud-functions-to-make-a-cheap-d64e8f5805d1

const path = require('path');
const zlib = require('zlib');
const { PassThrough } = require('stream');

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const log = require('firebase-functions/lib/logger');

const sharp = require('sharp');

const { empty } = require('ramda');

const SHARP_FORMATS = Object.values(sharp.format);
const SHARP_SUPPORTED_FORMATS = SHARP_FORMATS.filter(
({ input, output }) => input.buffer && output.buffer
).map(({ id }) => `image/${id}`);
const FORMAT_WEBP = 'image/webp';
const DEFAULT_MAX_AGE = '31536000';
const DEFAULT_CACHE_CONTROL = `public, max-age=${DEFAULT_MAX_AGE}, s-maxage=${DEFAULT_MAX_AGE}`;

// Error deploying, Firebase app already exists
!admin.apps.length ? admin.initializeApp() : admin.app();

let db = admin.firestore();
let FieldValue = admin.firestore.FieldValue;

const runtimeOpts = {
timeoutSeconds: 20,
memory: '1GB',
};

exports.dynamicImages = functions
.runWith(runtimeOpts)
.https.onRequest(async (request, response) => {
const bucket = admin.storage().bucket('images.showcasebase.com');
// From the URL we want to get some params
const { query, params } = request;
const { 0: urlparam = '' } = params;
// Parse these params to integers
let width = query.w && parseInt(query.w);
let height = query.h && parseInt(query.h);
const quality = query.q && parseInt(query.q);
const dpr = query.dpr && parseInt(query.dpr);
if (dpr && !isNaN(dpr)) {
width = width && !isNaN(width) ? width * dpr : width;
height = height && !isNaN(height) ? height * dpr : width;
}
// We need to strip the leading "/" in he URL
const filepath = urlparam.replace(/^dynamic\/+/, '');
// EncodeURI to deal with certain characters i.e. in Duลกkovรก
filepath = encodeURI(filepath);
// If you don't have a filepath then return a 404
if (!filepath || !filepath.length) {
log.warn(
`No filepath passed in URL. Params:`,
request.params,
'Query:',
empty(request.query) ? 'none provided' : request.query
);
response.sendStatus(400);
return;
}
// Check that the request params can be made into numbers
if (
(width && isNaN(width)) ||
(height && isNaN(height)) ||
(quality && isNaN(quality))
) {
log.warn(
`Non NaN parameters in the query. Width: ${width} Height: ${height} Quality: ${quality}`
);
response.sendStatus(400);
return;
}
log.info(
`Resizing ${filepath} to width: ${width || 'auto'} height: ${
height || 'auto'
}`
);
// Get a ref to the file
const ref = bucket.file(filepath);
const isExists = await ref.exists();
// If the file doesn't exist then we 404
if (!isExists) {
log.warn(`404 returned for ${filepath}`);
response.sendStatus(404);
return;
}
// Get the metadata we will need for this file
const {
contentType: sourceContentType,
cacheControl: sourceCacheControl,
name: sourcePath,
} = ref.metadata;
// Does sharp accept this contentType
const isSupportedBySharp =
SHARP_SUPPORTED_FORMATS.includes(sourceContentType);
// Will we be doing a transform
const isTransformed = Boolean(
isSupportedBySharp && (width || height || quality)
);
// What will our output format. Namely, can we send webp back.
const isWebpTransform = !!request.accepts(FORMAT_WEBP) && isTransformed;
// Get the filename and amend with webp if needed
const contentType = isWebpTransform ? FORMAT_WEBP : sourceContentType;
const sourceext = path.extname(sourcePath);
const filename = path.basename(sourcePath, sourceext);
// Create the content disposition
const dispositionfilename = isWebpTransform
? filename + '.webp'
: filename + sourceext;
// Create some opts for Sharp
// Do this seperately because the typing on it is bad
const resizeOpts = { width, height, fit: 'inside' };
const formatOpts = { quality };
const format = SHARP_FORMATS.find(
(f) => f.id === contentType.replace('image/', '')
);
log.debug(formatOpts);
// What sort of compression can we supply
let encoder;
let encodingAlogrithm;
const encodingPreference = request.acceptsEncodings();
// We start with brotli. Then we just pick your preference
if (encodingPreference.includes('br')) {
encoder = zlib.createBrotliCompress();
encodingAlogrithm = 'br';
} else if (encodingPreference[0] === 'gzip') {
encoder = zlib.createGzip();
encodingAlogrithm = 'gzip';
} else if (encodingPreference[0] === 'deflate') {
encoder = zlib.createDeflate();
encodingAlogrithm = 'deflate';
} else encoder = new PassThrough();
log.log(
`Encoding with ${encodingAlogrithm} given encoding preference`,
encodingPreference
);
// Create each header
const cacheControl = sourceCacheControl || DEFAULT_CACHE_CONTROL;
const contentDisposition = `inline; filename=${dispositionfilename}`;
const contentEncoding = encodingAlogrithm;
const xIsTransformed = isTransformed.toString();
const xGeneration = new Date().toISOString();
// Write our response headers
response.setHeader('x-gfn-istransformed', xIsTransformed);
response.setHeader('x-gfn-generation', xGeneration);
response.setHeader('Cache-Control', cacheControl);
response.setHeader('Content-Disposition', contentDisposition);
contentEncoding && response.setHeader('Content-Encoding', contentEncoding);
response.contentType(contentType);
// If we want to do a transform then we pipe to Sharp.
// Otherwise pipe direct to the response
const pipeline = sharp();
const destination = isTransformed ? pipeline : encoder;
log.log(`Piping out put to ${isTransformed ? 'Sharp' : 'response'}`);
// Pipe the sharp stream back to the browser
ref.createReadStream().pipe(destination);
// Pipe the sharp pipeline to the response
if (isTransformed)
pipeline.resize(resizeOpts).toFormat(format, formatOpts).pipe(encoder);
// Pipe the encoder out to the response
encoder.pipe(response);
// Increment db to count transformed image
db.collection('stats')
.doc('imgResized')
.update({ count: FieldValue.increment(1) });
});
  1. Next/Image Optimization
    danger

    requires SSR deployment (not SSG)

import Image from 'next/image';
<Image
src={profilePic}
alt="Picture of the author"
// width={500} automatically provided
// height={500} automatically provided
// blurDataURL="data:..." automatically provided
// placeholder="blur" // Optional blur-up while loading
/>;

CloudImage.ioโ€‹

info

CloudImage.io has a great free tier, allows us to outsource image processing and has several plugins for React, Vue and JavaScript that detect the image container size and deliver the appropriate image. CloudImage Plugin

Firebase Function Resize Extensionโ€‹

Resize image to 5000x5000 save as .jpg and delete original.