From b96289b36f39828fb403b1d0c71493e180ca2587 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Tue, 12 Mar 2024 17:21:52 +0100 Subject: [PATCH] feat(asset-server-plugin): Add `q` query param for dynamic quality --- packages/asset-server-plugin/src/plugin.ts | 45 ++++++++++++++----- .../src/transform-image.ts | 39 +++++++++++++--- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/packages/asset-server-plugin/src/plugin.ts b/packages/asset-server-plugin/src/plugin.ts index b19da32d73..d42938bf4c 100644 --- a/packages/asset-server-plugin/src/plugin.ts +++ b/packages/asset-server-plugin/src/plugin.ts @@ -11,7 +11,6 @@ import { } from '@vendure/core'; import { createHash } from 'crypto'; import express, { NextFunction, Request, Response } from 'express'; -import { fileTypeFromBuffer } from 'file-type'; import fs from 'fs-extra'; import path from 'path'; @@ -23,6 +22,11 @@ import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy'; import { transformImage } from './transform-image'; import { AssetServerOptions, ImageTransformPreset } from './types'; +async function getFileType(buffer: Buffer) { + const { fileTypeFromBuffer } = await import('file-type'); + return fileTypeFromBuffer(buffer); +} + /** * @description * The `AssetServerPlugin` serves assets (images and other files) from the local file system, and can also be configured to use @@ -96,6 +100,16 @@ import { AssetServerOptions, ImageTransformPreset } from './types'; * * The `format` parameter can also be combined with presets (see below). * + * ### Quality + * + * Since v2.2.0, the image quality can be specified by adding the `q` query parameter: + * + * `http://localhost:3000/assets/some-asset.jpg?q=75` + * + * This applies to the `jpg`, `webp` and `avif` formats. The default quality value for `jpg` and `webp` is 80, and for `avif` is 50. + * + * The `q` parameter can also be combined with presets (see below). + * * ### Transform presets * * Presets can be defined which allow a single preset name to be used instead of specifying the width, height and mode. Presets are @@ -244,7 +258,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { const file = await AssetServerPlugin.assetStorage.readFileToBuffer(key); let mimeType = this.getMimeType(key); if (!mimeType) { - mimeType = (await fileTypeFromBuffer(file))?.mime || 'application/octet-stream'; + mimeType = (await getFileType(file))?.mime || 'application/octet-stream'; } res.contentType(mimeType); res.setHeader('content-security-policy', "default-src 'self'"); @@ -289,7 +303,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { } let mimeType = this.getMimeType(cachedFileName); if (!mimeType) { - mimeType = (await fileTypeFromBuffer(imageBuffer))?.mime || 'image/jpeg'; + mimeType = (await getFileType(imageBuffer))?.mime || 'image/jpeg'; } res.set('Content-Type', mimeType); res.setHeader('content-security-policy', "default-src 'self'"); @@ -307,26 +321,37 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { } private getFileNameFromRequest(req: Request): string { - const { w, h, mode, preset, fpx, fpy, format } = req.query; + const { w, h, mode, preset, fpx, fpy, format, q } = req.query; /* eslint-disable @typescript-eslint/restrict-template-expressions */ const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : ''; + const quality = q ? `_q${q}` : ''; const imageFormat = getValidFormat(format); - let imageParamHash: string | null = null; + let imageParamsString = ''; if (w || h) { const width = w || ''; const height = h || ''; - imageParamHash = this.md5(`_transform_w${width}_h${height}_m${mode}${focalPoint}${imageFormat}`); + imageParamsString = `_transform_w${width}_h${height}_m${mode}`; } else if (preset) { if (this.presets && !!this.presets.find(p => p.name === preset)) { - imageParamHash = this.md5(`_transform_pre_${preset}${focalPoint}${imageFormat}`); + imageParamsString = `_transform_pre_${preset}`; } - } else if (imageFormat) { - imageParamHash = this.md5(`_transform_${imageFormat}`); } + + if (focalPoint) { + imageParamsString += focalPoint; + } + if (imageFormat) { + imageParamsString += imageFormat; + } + if (quality) { + imageParamsString += quality; + } + /* eslint-enable @typescript-eslint/restrict-template-expressions */ const decodedReqPath = decodeURIComponent(req.path); - if (imageParamHash) { + if (imageParamsString !== '') { + const imageParamHash = this.md5(imageParamsString); return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat)); } else { return decodedReqPath; diff --git a/packages/asset-server-plugin/src/transform-image.ts b/packages/asset-server-plugin/src/transform-image.ts index 2fad84fff3..623bcd3878 100644 --- a/packages/asset-server-plugin/src/transform-image.ts +++ b/packages/asset-server-plugin/src/transform-image.ts @@ -1,6 +1,8 @@ -import sharp, { Region, ResizeOptions } from 'sharp'; +import { Logger } from '@vendure/core'; +import sharp, { FormatEnum, Region, ResizeOptions } from 'sharp'; import { getValidFormat } from './common'; +import { loggerCtx } from './constants'; import { ImageTransformFormat, ImageTransformPreset } from './types'; export type Dimensions = { w: number; h: number }; @@ -16,6 +18,8 @@ export async function transformImage( ): Promise { let targetWidth = Math.round(+queryParams.w) || undefined; let targetHeight = Math.round(+queryParams.h) || undefined; + const quality = + queryParams.q != null ? Math.round(Math.max(Math.min(+queryParams.q, 100), 1)) : undefined; let mode = queryParams.mode || 'crop'; const fpx = +queryParams.fpx || undefined; const fpy = +queryParams.fpy || undefined; @@ -36,7 +40,11 @@ export async function transformImage( } const image = sharp(originalImage); - applyFormat(image, imageFormat); + try { + await applyFormat(image, imageFormat, quality); + } catch (e: any) { + Logger.error(e.message, loggerCtx, e.stack); + } if (fpx && fpy && targetWidth && targetHeight && mode === 'crop') { const metadata = await image.metadata(); if (metadata.width && metadata.height) { @@ -54,22 +62,39 @@ export async function transformImage( return image.resize(targetWidth, targetHeight, options); } -function applyFormat(image: sharp.Sharp, format: ImageTransformFormat | undefined) { +async function applyFormat( + image: sharp.Sharp, + format: ImageTransformFormat | undefined, + quality: number | undefined, +) { switch (format) { case 'jpg': case 'jpeg': - return image.jpeg(); + return image.jpeg({ quality }); case 'png': return image.png(); case 'webp': - return image.webp(); + return image.webp({ quality }); case 'avif': - return image.avif(); - default: + return image.avif({ quality }); + default: { + if (quality) { + // If a quality has been specified but no format, we need to determine the format from the image + // and apply the quality to that format. + const metadata = await image.metadata(); + if (isImageTransformFormat(metadata.format)) { + return applyFormat(image, metadata.format, quality); + } + } return image; + } } } +function isImageTransformFormat(input: keyof FormatEnum | undefined): input is ImageTransformFormat { + return !!input && ['jpg', 'jpeg', 'webp', 'avif'].includes(input); +} + /** * Resize an image but keep it centered on the focal point. * Based on the method outlined in https://github.com/lovell/sharp/issues/1198#issuecomment-384591756