Переглянути джерело

feat(asset-server-plugin): Add `q` query param for dynamic quality

Michael Bromley 1 рік тому
батько
коміт
b96289b36f

+ 35 - 10
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;

+ 32 - 7
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<sharp.Sharp> {
     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