Browse Source

feat(asset-server-plugin): Implement focal point-aware cropping

Relates to #93
Michael Bromley 6 years ago
parent
commit
5fef77da59

+ 1 - 0
packages/asset-server-plugin/index.ts

@@ -1,2 +1,3 @@
 export * from './src/plugin';
 export * from './src/sharp-asset-preview-strategy';
+export * from './src/types';

+ 13 - 0
packages/asset-server-plugin/jest.config.js

@@ -0,0 +1,13 @@
+module.exports = {
+    coverageDirectory: "coverage",
+    moduleFileExtensions: [
+        "js",
+        "json",
+        "ts",
+    ],
+    preset: "ts-jest",
+    rootDir: __dirname,
+    transform: {
+        "^.+\\.(t|j)s$": "ts-jest",
+    },
+};

+ 2 - 1
packages/asset-server-plugin/package.json

@@ -10,7 +10,8 @@
   "scripts": {
     "watch": "tsc -p ./tsconfig.build.json --watch",
     "build": "rimraf lib && tsc -p ./tsconfig.build.json",
-    "lint": "tslint --fix --project ./"
+    "lint": "tslint --fix --project ./",
+    "test": "jest --config ./jest.config.js"
   },
   "publishConfig": {
     "access": "public"

+ 42 - 104
packages/asset-server-plugin/src/plugin.ts

@@ -3,6 +3,7 @@ import {
     AssetStorageStrategy,
     createProxyHandler,
     LocalAssetStorageStrategy,
+    Logger,
     OnVendureBootstrap,
     OnVendureClose,
     RuntimeVendureConfig,
@@ -14,97 +15,7 @@ import path from 'path';
 
 import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy';
 import { transformImage } from './transform-image';
-
-/**
- * @description
- * Specifies the way in which an asset preview image will be resized to fit in the
- * proscribed dimensions:
- *
- * * crop: crops the image to cover both provided dimensions
- * * resize: Preserving aspect ratio, resizes the image to be as large as possible
- * while ensuring its dimensions are less than or equal to both those specified.
- *
- * @docsCategory AssetServerPlugin
- */
-export type ImageTransformMode = 'crop' | 'resize';
-
-/**
- * @description
- * A configuration option for an image size preset for the AssetServerPlugin.
- *
- * Presets allow a shorthand way to generate a thumbnail preview of an asset. For example,
- * the built-in "tiny" preset generates a 50px x 50px cropped preview, which can be accessed
- * by appending the string `preset=tiny` to the asset url:
- *
- * `http://localhost:3000/assets/some-asset.jpg?preset=tiny`
- *
- * is equivalent to:
- *
- * `http://localhost:3000/assets/some-asset.jpg?w=50&h=50&mode=crop`
- *
- * @docsCategory AssetServerPlugin
- */
-export interface ImageTransformPreset {
-    name: string;
-    width: number;
-    height: number;
-    mode: ImageTransformMode;
-}
-
-/**
- * @description
- * The configuration options for the AssetServerPlugin.
- *
- * @docsCategory AssetServerPlugin
- */
-export interface AssetServerOptions {
-    hostname?: string;
-    /**
-     * @description
-     * The local port that the server will run on. Note that the AssetServerPlugin
-     * includes a proxy server which allows the asset server to be accessed on the same
-     * port as the main Vendure server.
-     */
-    port: number;
-    /**
-     * @description
-     * The proxy route to the asset server.
-     */
-    route: string;
-    /**
-     * @description
-     * The local directory to which assets will be uploaded.
-     */
-    assetUploadDir: string;
-    /**
-     * @description
-     * The complete URL prefix of the asset files. For example, "https://demo.vendure.io/assets/"
-     *
-     * If not provided, the plugin will attempt to guess based off the incoming
-     * request and the configured route. However, in all but the simplest cases,
-     * this guess may not yield correct results.
-     */
-    assetUrlPrefix?: string;
-    /**
-     * @description
-     * The max width in pixels of a generated preview image.
-     *
-     * @default 1600
-     */
-    previewMaxWidth?: number;
-    /**
-     * @description
-     * The max height in pixels of a generated preview image.
-     *
-     * @default 1600
-     */
-    previewMaxHeight?: number;
-    /**
-     * @description
-     * An array of additional {@link ImageTransformPreset} objects.
-     */
-    presets?: ImageTransformPreset[];
-}
+import { AssetServerOptions, ImageTransformPreset } from './types';
 
 /**
  * @description
@@ -149,6 +60,20 @@ export interface AssetServerOptions {
  *
  * The `mode` parameter can be either `crop` or `resize`. See the [ImageTransformMode]({{< relref "image-transform-mode" >}}) docs for details.
  *
+ * ### Focal point
+ *
+ * When cropping an image (`mode=crop`), Vendure will attempt to keep the most "interesting" area of the image in the cropped frame. It does this
+ * by finding the area of the image with highest entropy (the busiest area of the image). However, sometimes this does not yield a satisfactory
+ * result - part or all of the main subject may still be cropped out.
+ *
+ * This is where specifying the focal point can help. The focal point of the image may be specified by passing the `fpx` and `fpy` query parameters.
+ * These are normalized coordinates (i.e. a number between 0 and 1), so the `fpx=0&fpy=0` corresponds to the top left of the image.
+ *
+ * For example, let's say there is a very wide landscape image which we want to crop to be square. The main subject is a house to the far left of the
+ * image. The following query would crop it to a square with the house centered:
+ *
+ * `http://localhost:3000/assets/landscape.jpg?w=150&h=150&mode=crop&fpx=0.2&fpy=0.7`
+ *
  * ### 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
@@ -298,11 +223,16 @@ export class AssetServerPlugin implements OnVendureBootstrap, OnVendureClose {
                         return;
                     }
                     const image = await transformImage(file, req.query, this.presets || []);
-                    const imageBuffer = await image.toBuffer();
-                    const cachedFileName = this.getFileNameFromRequest(req);
-                    await AssetServerPlugin.assetStorage.writeFileFromBuffer(cachedFileName, imageBuffer);
-                    res.set('Content-Type', `image/${(await image.metadata()).format}`);
-                    res.send(imageBuffer);
+                    try {
+                        const imageBuffer = await image.toBuffer();
+                        const cachedFileName = this.getFileNameFromRequest(req);
+                        await AssetServerPlugin.assetStorage.writeFileFromBuffer(cachedFileName, imageBuffer);
+                        res.set('Content-Type', `image/${(await image.metadata()).format}`);
+                        res.send(imageBuffer);
+                    } catch (e) {
+                        Logger.error(e, 'AssetServerPlugin', e.stack);
+                        res.status(500).send(e.message);
+                    }
                 }
             }
             next();
@@ -310,14 +240,22 @@ export class AssetServerPlugin implements OnVendureBootstrap, OnVendureClose {
     }
 
     private getFileNameFromRequest(req: Request): string {
-        if (req.query.w || req.query.h) {
-            const width = req.query.w || '';
-            const height = req.query.h || '';
-            const mode = req.query.mode || '';
-            return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_w${width}_h${height}_m${mode}`);
-        } else if (req.query.preset) {
-            if (this.presets && !!this.presets.find(p => p.name === req.query.preset)) {
-                return this.cacheDir + '/' + this.addSuffix(req.path, `_transform_pre_${req.query.preset}`);
+        const { w, h, mode, preset, fpx, fpy } = req.query;
+        const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
+        if (w || h) {
+            const width = w || '';
+            const height = h || '';
+            // TODO: make sure no bug / attack vector with the naming of files
+            return (
+                this.cacheDir +
+                '/' +
+                this.addSuffix(req.path, `_transform_w${width}_h${height}_m${mode}${focalPoint}`)
+            );
+        } else if (preset) {
+            if (this.presets && !!this.presets.find(p => p.name === preset)) {
+                return (
+                    this.cacheDir + '/' + this.addSuffix(req.path, `_transform_pre_${preset}${focalPoint}`)
+                );
             }
         }
         return req.path;

+ 67 - 0
packages/asset-server-plugin/src/transform-image.spec.ts

@@ -0,0 +1,67 @@
+import { Dimensions, Point, resizeToFocalPoint } from './transform-image';
+
+describe('resizeToFocalPoint', () => {
+    it('no resize, crop left', () => {
+        const original: Dimensions = { w: 200, h: 100 };
+        const target: Dimensions = { w: 100, h: 100 };
+        const focalPoint: Point = { x: 50, y: 50 };
+        const result = resizeToFocalPoint(original, target, focalPoint);
+
+        expect(result.width).toBe(200);
+        expect(result.height).toBe(100);
+        expect(result.region).toEqual({
+            left: 0,
+            top: 0,
+            width: 100,
+            height: 100,
+        });
+    });
+
+    it('no resize, crop top left', () => {
+        const original: Dimensions = { w: 200, h: 100 };
+        const target: Dimensions = { w: 100, h: 100 };
+        const focalPoint: Point = { x: 0, y: 0 };
+        const result = resizeToFocalPoint(original, target, focalPoint);
+
+        expect(result.width).toBe(200);
+        expect(result.height).toBe(100);
+        expect(result.region).toEqual({
+            left: 0,
+            top: 0,
+            width: 100,
+            height: 100,
+        });
+    });
+
+    it('no resize, crop center', () => {
+        const original: Dimensions = { w: 200, h: 100 };
+        const target: Dimensions = { w: 100, h: 100 };
+        const focalPoint: Point = { x: 100, y: 50 };
+        const result = resizeToFocalPoint(original, target, focalPoint);
+
+        expect(result.width).toBe(200);
+        expect(result.height).toBe(100);
+        expect(result.region).toEqual({
+            left: 50,
+            top: 0,
+            width: 100,
+            height: 100,
+        });
+    });
+
+    it('crop with resize', () => {
+        const original: Dimensions = { w: 200, h: 100 };
+        const target: Dimensions = { w: 25, h: 50 };
+        const focalPoint: Point = { x: 50, y: 50 };
+        const result = resizeToFocalPoint(original, target, focalPoint);
+
+        expect(result.width).toBe(100);
+        expect(result.height).toBe(50);
+        expect(result.region).toEqual({
+            left: 13,
+            top: 0,
+            width: 25,
+            height: 50,
+        });
+    });
+});

+ 101 - 8
packages/asset-server-plugin/src/transform-image.ts

@@ -1,7 +1,9 @@
-import sharp from 'sharp';
-import { ResizeOptions } from 'sharp';
+import sharp, { Region, ResizeOptions } from 'sharp';
 
-import { ImageTransformPreset } from './plugin';
+import { ImageTransformPreset } from './types';
+
+export type Dimensions = { w: number; h: number };
+export type Point = { x: number; y: number };
 
 /**
  * Applies transforms to the given image according to the query params passed.
@@ -11,14 +13,16 @@ export async function transformImage(
     queryParams: Record<string, string>,
     presets: ImageTransformPreset[],
 ): Promise<sharp.Sharp> {
-    let width = +queryParams.w || undefined;
-    let height = +queryParams.h || undefined;
+    let targetWidth = +queryParams.w || undefined;
+    let targetHeight = +queryParams.h || undefined;
     let mode = queryParams.mode || 'crop';
+    const fpx = +queryParams.fpx || undefined;
+    const fpy = +queryParams.fpy || undefined;
     if (queryParams.preset) {
         const matchingPreset = presets.find(p => p.name === queryParams.preset);
         if (matchingPreset) {
-            width = matchingPreset.width;
-            height = matchingPreset.height;
+            targetWidth = matchingPreset.width;
+            targetHeight = matchingPreset.height;
             mode = matchingPreset.mode;
         }
     }
@@ -28,5 +32,94 @@ export async function transformImage(
     } else {
         options.fit = 'inside';
     }
-    return sharp(originalImage).resize(width, height, options);
+
+    const image = sharp(originalImage);
+    if (fpx && fpy && targetWidth && targetHeight && mode === 'crop') {
+        const metadata = await image.metadata();
+        if (metadata.width && metadata.height) {
+            const xCenter = fpx * metadata.width;
+            const yCenter = fpy * metadata.height;
+            const { width, height, region } = resizeToFocalPoint(
+                { w: metadata.width, h: metadata.height },
+                { w: targetWidth, h: targetHeight },
+                { x: xCenter, y: yCenter },
+            );
+            return image.resize(width, height).extract(region);
+        }
+    }
+
+    return image.resize(targetWidth, targetHeight, options);
+}
+
+/**
+ * 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
+ */
+export function resizeToFocalPoint(
+    original: Dimensions,
+    target: Dimensions,
+    focalPoint: Point,
+): { width: number; height: number; region: Region } {
+    const { width, height, factor } = getIntermediateDimensions(original, target);
+    const region = getExtractionRegion(factor, focalPoint, target, { w: width, h: height });
+    return { width, height, region };
+}
+
+/**
+ * Calculates the dimensions of the intermediate (resized) image.
+ */
+function getIntermediateDimensions(
+    original: Dimensions,
+    target: Dimensions,
+): { width: number; height: number; factor: number } {
+    const hRatio = original.h / target.h;
+    const wRatio = original.w / target.w;
+
+    let factor: number;
+    let width: number;
+    let height: number;
+
+    if (hRatio < wRatio) {
+        factor = hRatio;
+        height = Math.round(target.h);
+        width = Math.round(original.w / factor);
+    } else {
+        factor = wRatio;
+        width = Math.round(target.w);
+        height = Math.round(original.h / factor);
+    }
+    return { width, height, factor };
+}
+
+/**
+ * Calculates the Region to extract from the intermediate image.
+ */
+function getExtractionRegion(
+    factor: number,
+    focalPoint: Point,
+    target: Dimensions,
+    intermediate: Dimensions,
+): Region {
+    const newXCenter = focalPoint.x / factor;
+    const newYCenter = focalPoint.y / factor;
+    const region: Region = {
+        left: 0,
+        top: 0,
+        width: target.w,
+        height: target.h,
+    };
+
+    if (intermediate.h < intermediate.w) {
+        region.left = clamp(0, intermediate.w - target.w, Math.round(newXCenter - target.w / 2));
+    } else {
+        region.top = clamp(0, intermediate.h - target.h, Math.round(newYCenter - target.h / 2));
+    }
+    return region;
+}
+
+/**
+ * Limit the input value to the specified min and max values.
+ */
+function clamp(min: number, max: number, input: number) {
+    return Math.min(Math.max(min, input), max);
 }

+ 90 - 0
packages/asset-server-plugin/src/types.ts

@@ -0,0 +1,90 @@
+/**
+ * @description
+ * Specifies the way in which an asset preview image will be resized to fit in the
+ * proscribed dimensions:
+ *
+ * * crop: crops the image to cover both provided dimensions
+ * * resize: Preserving aspect ratio, resizes the image to be as large as possible
+ * while ensuring its dimensions are less than or equal to both those specified.
+ *
+ * @docsCategory AssetServerPlugin
+ */
+export type ImageTransformMode = 'crop' | 'resize';
+
+/**
+ * @description
+ * A configuration option for an image size preset for the AssetServerPlugin.
+ *
+ * Presets allow a shorthand way to generate a thumbnail preview of an asset. For example,
+ * the built-in "tiny" preset generates a 50px x 50px cropped preview, which can be accessed
+ * by appending the string `preset=tiny` to the asset url:
+ *
+ * `http://localhost:3000/assets/some-asset.jpg?preset=tiny`
+ *
+ * is equivalent to:
+ *
+ * `http://localhost:3000/assets/some-asset.jpg?w=50&h=50&mode=crop`
+ *
+ * @docsCategory AssetServerPlugin
+ */
+export interface ImageTransformPreset {
+    name: string;
+    width: number;
+    height: number;
+    mode: ImageTransformMode;
+}
+
+/**
+ * @description
+ * The configuration options for the AssetServerPlugin.
+ *
+ * @docsCategory AssetServerPlugin
+ */
+export interface AssetServerOptions {
+    hostname?: string;
+    /**
+     * @description
+     * The local port that the server will run on. Note that the AssetServerPlugin
+     * includes a proxy server which allows the asset server to be accessed on the same
+     * port as the main Vendure server.
+     */
+    port: number;
+    /**
+     * @description
+     * The proxy route to the asset server.
+     */
+    route: string;
+    /**
+     * @description
+     * The local directory to which assets will be uploaded.
+     */
+    assetUploadDir: string;
+    /**
+     * @description
+     * The complete URL prefix of the asset files. For example, "https://demo.vendure.io/assets/"
+     *
+     * If not provided, the plugin will attempt to guess based off the incoming
+     * request and the configured route. However, in all but the simplest cases,
+     * this guess may not yield correct results.
+     */
+    assetUrlPrefix?: string;
+    /**
+     * @description
+     * The max width in pixels of a generated preview image.
+     *
+     * @default 1600
+     */
+    previewMaxWidth?: number;
+    /**
+     * @description
+     * The max height in pixels of a generated preview image.
+     *
+     * @default 1600
+     */
+    previewMaxHeight?: number;
+    /**
+     * @description
+     * An array of additional {@link ImageTransformPreset} objects.
+     */
+    presets?: ImageTransformPreset[];
+}