pmb0/express-sharp

View on GitHub
src/transformer.service.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import Keyv from 'keyv'
import sharp from 'sharp'
import { singleton } from 'tsyringe'
import { CachedImage } from './cached-image'
import { format, ImageAdapter, Result } from './interfaces'
import { getLogger } from './logger'
import { ObjectHash } from './object-hash.service'
import { ResizeDto } from './resize.dto'

const DEFAULT_CROP_MAX_SIZE = 2000

@singleton()
export class Transformer {
  log = getLogger('transformer')
  cropMaxSize = DEFAULT_CROP_MAX_SIZE

  constructor(
    private readonly objectHasher: ObjectHash,
    private readonly cache: Keyv<Result>,
    private readonly cachedOriginalImage: CachedImage,
  ) {}

  getCropDimensions(maxSize: number, width: number, height?: number): number[] {
    height = height || width

    if (width <= maxSize && height <= maxSize) {
      return [width, height]
    }

    const aspectRatio = width / height

    if (width > height) {
      return [maxSize, Math.round(maxSize / aspectRatio)]
    }

    return [maxSize * aspectRatio, maxSize].map((number) => Math.round(number))
  }

  buildCacheKey(id: string, options: ResizeDto, adapterName: string): string {
    const hash = this.objectHasher.hash(options)
    return `transform:${id}:${adapterName}:${hash}`
  }

  async transform(
    id: string,
    options: ResizeDto,
    imageAdapter: ImageAdapter,
  ): Promise<Result> {
    const cacheKey = this.buildCacheKey(
      id,
      options,
      imageAdapter.constructor.name,
    )

    const cachedImage = await this.cache.get(cacheKey)
    if (cachedImage) {
      this.log(`Serving ${id} from cache ...`)
      return cachedImage
    }

    this.log(`Resizing ${id} with options:`, JSON.stringify(options))

    const originalImage = await this.cachedOriginalImage.fetch(id, imageAdapter)

    if (!originalImage) {
      return {
        format: options.format,
        // eslint-disable-next-line unicorn/no-null
        image: null,
      }
    }

    const transformer = sharp(originalImage).rotate()

    if (!options.format) {
      options.format = (await transformer.metadata()).format as format
    }

    if (options.trim) {
      transformer.trim()
    }

    if (options.crop) {
      const [cropWidth, cropHeight] = this.getCropDimensions(
        this.cropMaxSize,
        options.width,
        options.height,
      )
      transformer.resize(cropWidth, cropHeight, {
        position: options.gravity,
      })
    } else {
      transformer.resize(options.width, options.height, {
        fit: 'inside',
        withoutEnlargement: true,
      })
    }

    const image = await transformer
      .toFormat(options.format, {
        progressive: options.progressive,
        quality: options.quality,
      })
      .toBuffer()

    this.log('Resizing done')

    const result = { format: options.format, image }

    this.log(`Caching ${cacheKey} ...`)
    await this.cache.set(cacheKey, result)
    return result
  }
}