Skip to content

About

Image thumbnails and resizes with sharp for Cedar's derived files

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@cedarjs/uploads-sharp

Image thumbnails and resizes for Cedar's derived files (defineDerivedFiles() in @cedarjs/uploads), built on sharp. Experimental, like derived files.

Install

yarn workspace api add @cedarjs/uploads-sharp sharp

Usage

import { sharpVariant } from '@cedarjs/uploads-sharp'

// In defineDerivedFiles()
variants: {
  // 164 × 164, cropped from the middle, as WebP
  'thumb-164': sharpVariant({ width: 164 }),
  // The whole photo, at most 1600 × 1200, as JPEG
  large: sharpVariant({
    width: 1600,
    height: 1200,
    fit: 'inside',
    format: 'jpeg',
    quality: 85,
  }),
},
Option Default What it does
width (required) Width in pixels
height width Height in pixels
fit 'cover' 'cover' crops to exactly width × height, as a thumbnail tile needs, keeping the middle unless position says otherwise. 'inside' fits the whole image within them, and never enlarges it
position 'centre' For 'cover', the part kept: 'top', 'right top', 'right', 'right bottom', 'bottom', 'left bottom', 'left', 'left top', 'centre', or 'entropy' (the most detailed part) or 'attention' (the most eye-catching). 'top' suits document pages
format 'webp' 'webp', 'jpeg', 'png' or 'avif'
quality 80 1–100, for WebP, JPEG and AVIF

The image is turned the right way up by its EXIF orientation, and the made file carries no metadata (no GPS position, no camera). Only JPEG, PNG, WebP, GIF, AVIF and TIFF files are decoded, told apart by their bytes, not by the type a source claims: anything else, such as a PDF, gets null and is remembered as failed. Images over 100 megapixels are refused. A 'cover' image is exactly width × height, however small the photo, so that can be at most 100 megapixels too. Without sharp installed, or with sharp unable to load its native library on your platform, sharpVariant() throws when your app starts, saying how to fix it, rather than failing every file later.

HEIC photos

sharpVariant() doesn't read HEIC files, the format iPhones take photos in by default: sharp's prebuilt libvips has no HEVC decoder, for licensing reasons. A HEIC file gets null, and is remembered as failed, like a PDF. To make thumbnails of HEIC photos, decode them with a package of your own, such as heic-decode, and hand everything else to sharpVariant():

yarn workspace api add heic-decode
yarn workspace api add -D @types/heic-decode
import decodeHeic from 'heic-decode'
import sharp from 'sharp'

import { detectContentType } from '@cedarjs/uploads'
import type { DerivedFileGenerator } from '@cedarjs/uploads'
import { sharpVariant } from '@cedarjs/uploads-sharp'

// A size × size WebP from HEIC too
const thumb = (size: number): DerivedFileGenerator => {
  const fromSharp = sharpVariant({ width: size })

  return async (context) => {
    const { data } = await context.readSource()

    if (detectContentType(data) !== 'image/heic') {
      return fromSharp(context)
    }

    const { width, height, data: rgba } = await decodeHeic({ buffer: data })

    return {
      data: await sharp(Buffer.from(rgba), {
        raw: { width, height, channels: 4 },
        limitInputPixels: 100_000_000,
      })
        .resize(size, size, { fit: 'cover' })
        .webp()
        .toBuffer(),
      mimeType: 'image/webp',
    }
  }
}

Then use thumb(164) in variants instead of sharpVariant({ width: 164 }). readSource() reads the file once, so calling it here and again in sharpVariant() costs nothing. heic-decode decodes the whole image before sharp sees its size, so a huge HEIC uses that much memory first; for HEIC from untrusted sources, consider decoding it in a locked-down child process.

Running sharp in a locked-down child process

sharpVariant() runs sharp in your API process. For images from people or systems you don't trust, you can run sharp in a locked-down child process with @cedarjs/locked-child instead, with a generator of your own. sharp's libvips is native code: Node's permission model only decides whether a native addon may load at all (sharp needs allowAddons: true) and doesn't confine what it does once loaded, so for sharp the child process gives you an empty environment (none of your secrets), a separate process (a crash in libvips doesn't take your server down), and caps on its JavaScript heap, time and output size. The heap cap doesn't limit the memory libvips uses to decode an image; that needs an operating-system limit, such as a container's.

The child script lives in your api/src, where every kind of build finds it and ships it with sharp, cedar build --ud too with @cedarjs/locked-child's Vite plugin (see its README):

import sharp from 'sharp'

const size = Number(process.argv[2])

const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
  chunks.push(chunk)
}

// Only the decoders you need: every other libvips loader stays off
sharp.block({ operation: ['VipsForeignLoad'] })
sharp.unblock({
  operation: ['VipsForeignLoadJpegBuffer', 'VipsForeignLoadPngBuffer'],
})

const webp = await sharp(Buffer.concat(chunks), {
  // 'warning' also refuses JPEGs some phones write slightly off
  failOn: 'error',
  limitInputPixels: 100_000_000,
})
  .autoOrient()
  .resize(size, size, { fit: 'cover' })
  .webp()
  .toBuffer()

process.stdout.write(webp)
import {
  createConcurrencyLimiter,
  findChildScript,
  runLockedChild,
} from '@cedarjs/locked-child'
import { detectContentType } from '@cedarjs/uploads'
import type { DerivedFileGenerator } from '@cedarjs/uploads'

// Each child is a whole Node process: two at a time per server process
const limit = createConcurrencyLimiter(2)

const square =
  (size: number): DerivedFileGenerator =>
  async ({ readSource }) => {
    const { data } = await readSource()
    const type = detectContentType(data)

    if (type !== 'image/jpeg' && type !== 'image/png') {
      return null
    }

    const result = await limit(() =>
      runLockedChild({
        script: findChildScript(import.meta.dirname, 'square.child'),
        packages: ['sharp'],
        allowAddons: true,
        heapMb: 128,
        input: data,
        args: [String(size)],
        maxInputBytes: 40 * 1024 * 1024,
        maxOutputBytes: 1024 * 1024,
      }),
    )

    // Thrown, so it's logged, and tried again a few times
    if (!result.ok) {
      throw new Error(`no ${size} px square: ${result.message}`)
    }

    // The output is untrusted too: check it's what you asked for
    if (result.output.toString('latin1', 8, 12) !== 'WEBP') {
      throw new Error(`no ${size} px square: the output isn't a WebP`)
    }

    return { data: result.output, mimeType: 'image/webp' }
  }

Then use square(164) in variants as you would sharpVariant({ width: 164 }). See @cedarjs/locked-child's README for what it does and doesn't protect against.

Development

yarn install
yarn test
yarn typecheck
yarn format

About

Image thumbnails and resizes with sharp for Cedar's derived files

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages