From 51e4a80c3c81d7bbafb39e9ddb4ff3e9e53c0a77 Mon Sep 17 00:00:00 2001
From: Tony Sullivan <tony.f.sullivan@outlook.com>
Date: Wed, 31 Aug 2022 10:49:06 -0500
Subject: [PATCH] WIP: adding a service built on @squoosh/lib

---
 packages/integrations/image/package.json      |   2 +
 packages/integrations/image/src/endpoint.ts   |   1 +
 packages/integrations/image/src/index.ts      |   8 +-
 .../integrations/image/src/loaders/index.ts   |  86 ++++++++++-
 .../integrations/image/src/loaders/sharp.ts   |  78 +---------
 .../integrations/image/src/loaders/squoosh.ts | 134 ++++++++++++++++++
 .../integrations/image/src/utils/metadata.ts  |   9 +-
 pnpm-lock.yaml                                |  14 ++
 8 files changed, 251 insertions(+), 81 deletions(-)
 create mode 100644 packages/integrations/image/src/loaders/squoosh.ts

diff --git a/packages/integrations/image/package.json b/packages/integrations/image/package.json
index 2937f3dfb..f87765f26 100644
--- a/packages/integrations/image/package.json
+++ b/packages/integrations/image/package.json
@@ -23,6 +23,7 @@
     ".": "./dist/index.js",
     "./endpoint": "./dist/endpoint.js",
     "./sharp": "./dist/loaders/sharp.js",
+    "./squoosh": "./dist/loaders/squoosh.js",
     "./components": "./components/index.js",
     "./package.json": "./package.json",
     "./client": "./client.d.ts",
@@ -40,6 +41,7 @@
     "test": "mocha --exit --timeout 20000 test"
   },
   "dependencies": {
+    "@squoosh/lib": "^0.4.0",
     "image-size": "^1.0.2",
     "magic-string": "^0.25.9",
     "mime": "^3.0.0",
diff --git a/packages/integrations/image/src/endpoint.ts b/packages/integrations/image/src/endpoint.ts
index bb634cf0c..872403226 100644
--- a/packages/integrations/image/src/endpoint.ts
+++ b/packages/integrations/image/src/endpoint.ts
@@ -48,6 +48,7 @@ export const get: APIRoute = async ({ request }) => {
 			},
 		});
 	} catch (err: unknown) {
+		console.error(err);
 		return new Response(`Server Error: ${err}`, { status: 500 });
 	}
 };
diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts
index 01948c025..f0da97f1e 100644
--- a/packages/integrations/image/src/index.ts
+++ b/packages/integrations/image/src/index.ts
@@ -31,7 +31,7 @@ export interface IntegrationOptions {
 
 export default function integration(options: IntegrationOptions = {}): AstroIntegration {
 	const resolvedOptions = {
-		serviceEntryPoint: '@astrojs/image/sharp',
+		serviceEntryPoint: '@astrojs/image/squoosh',
 		logLevel: 'info' as LoggerLevel,
 		...options,
 	};
@@ -45,7 +45,11 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
 		return {
 			plugins: [createPlugin(_config, resolvedOptions)],
 			optimizeDeps: {
-				include: ['image-size', 'sharp'],
+				include: [
+					'image-size',
+					resolvedOptions.serviceEntryPoint === '@astrojs/image/sharp' && 'sharp',
+					resolvedOptions.serviceEntryPoint === '@astrojs/image/squoosh' && '@squoosh/lib',
+				].filter(Boolean),
 			},
 			ssr: {
 				noExternal: ['@astrojs/image', resolvedOptions.serviceEntryPoint],
diff --git a/packages/integrations/image/src/loaders/index.ts b/packages/integrations/image/src/loaders/index.ts
index 58a9924a8..9da71b448 100644
--- a/packages/integrations/image/src/loaders/index.ts
+++ b/packages/integrations/image/src/loaders/index.ts
@@ -10,10 +10,10 @@ export type InputFormat =
 	| 'webp'
 	| 'gif';
 
-export type OutputFormat = 'avif' | 'jpeg' | 'png' | 'webp';
+export type OutputFormat = 'avif' | 'jpeg' | 'jpg' | 'png' | 'webp';
 
 export function isOutputFormat(value: string): value is OutputFormat {
-	return ['avif', 'jpeg', 'png', 'webp'].includes(value);
+	return ['avif', 'jpeg', 'jpg', 'png', 'webp'].includes(value);
 }
 
 export function isAspectRatioString(value: string): value is `${number}:${number}` {
@@ -125,3 +125,85 @@ export function isHostedService(service: ImageService): service is ImageService
 export function isSSRService(service: ImageService): service is SSRImageService {
 	return 'transform' in service;
 }
+
+export abstract class BaseSSRService implements SSRImageService {
+	async getImageAttributes(transform: TransformOptions) {
+		// strip off the known attributes
+		const { width, height, src, format, quality, aspectRatio, ...rest } = transform;
+
+		return {
+			...rest,
+			width: width,
+			height: height,
+		};
+	}
+
+	serializeTransform(transform: TransformOptions) {
+		const searchParams = new URLSearchParams();
+
+		if (transform.quality) {
+			searchParams.append('q', transform.quality.toString());
+		}
+
+		if (transform.format) {
+			searchParams.append('f', transform.format);
+		}
+
+		if (transform.width) {
+			searchParams.append('w', transform.width.toString());
+		}
+
+		if (transform.height) {
+			searchParams.append('h', transform.height.toString());
+		}
+
+		if (transform.aspectRatio) {
+			searchParams.append('ar', transform.aspectRatio.toString());
+		}
+
+		searchParams.append('href', transform.src);
+
+		return { searchParams };
+	}
+
+	parseTransform(searchParams: URLSearchParams) {
+		if (!searchParams.has('href')) {
+			return undefined;
+		}
+
+		let transform: TransformOptions = { src: searchParams.get('href')! };
+
+		if (searchParams.has('q')) {
+			transform.quality = parseInt(searchParams.get('q')!);
+		}
+
+		if (searchParams.has('f')) {
+			const format = searchParams.get('f')!;
+			if (isOutputFormat(format)) {
+				transform.format = format;
+			}
+		}
+
+		if (searchParams.has('w')) {
+			transform.width = parseInt(searchParams.get('w')!);
+		}
+
+		if (searchParams.has('h')) {
+			transform.height = parseInt(searchParams.get('h')!);
+		}
+
+		if (searchParams.has('ar')) {
+			const ratio = searchParams.get('ar')!;
+
+			if (isAspectRatioString(ratio)) {
+				transform.aspectRatio = ratio;
+			} else {
+				transform.aspectRatio = parseFloat(ratio);
+			}
+		}
+
+		return transform;
+	}
+
+	abstract transform(inputBuffer: Buffer, transform: TransformOptions): Promise<{ data: Buffer, format: OutputFormat }>;
+}
diff --git a/packages/integrations/image/src/loaders/sharp.ts b/packages/integrations/image/src/loaders/sharp.ts
index 4e7b3f104..591023ee4 100644
--- a/packages/integrations/image/src/loaders/sharp.ts
+++ b/packages/integrations/image/src/loaders/sharp.ts
@@ -1,80 +1,8 @@
 import sharp from 'sharp';
-import { isAspectRatioString, isOutputFormat } from '../loaders/index.js';
-import type { OutputFormat, SSRImageService, TransformOptions } from './index.js';
-
-class SharpService implements SSRImageService {
-	async getImageAttributes(transform: TransformOptions) {
-		// strip off the known attributes
-		const { width, height, src, format, quality, aspectRatio, ...rest } = transform;
-
-		return {
-			...rest,
-			width: width,
-			height: height,
-		};
-	}
-
-	serializeTransform(transform: TransformOptions) {
-		const searchParams = new URLSearchParams();
-
-		if (transform.quality) {
-			searchParams.append('q', transform.quality.toString());
-		}
-
-		if (transform.format) {
-			searchParams.append('f', transform.format);
-		}
-
-		if (transform.width) {
-			searchParams.append('w', transform.width.toString());
-		}
-
-		if (transform.height) {
-			searchParams.append('h', transform.height.toString());
-		}
-
-		if (transform.aspectRatio) {
-			searchParams.append('ar', transform.aspectRatio.toString());
-		}
-
-		return { searchParams };
-	}
-
-	parseTransform(searchParams: URLSearchParams) {
-		let transform: TransformOptions = { src: searchParams.get('href')! };
-
-		if (searchParams.has('q')) {
-			transform.quality = parseInt(searchParams.get('q')!);
-		}
-
-		if (searchParams.has('f')) {
-			const format = searchParams.get('f')!;
-			if (isOutputFormat(format)) {
-				transform.format = format;
-			}
-		}
-
-		if (searchParams.has('w')) {
-			transform.width = parseInt(searchParams.get('w')!);
-		}
-
-		if (searchParams.has('h')) {
-			transform.height = parseInt(searchParams.get('h')!);
-		}
-
-		if (searchParams.has('ar')) {
-			const ratio = searchParams.get('ar')!;
-
-			if (isAspectRatioString(ratio)) {
-				transform.aspectRatio = ratio;
-			} else {
-				transform.aspectRatio = parseFloat(ratio);
-			}
-		}
-
-		return transform;
-	}
+import { BaseSSRService } from '../loaders/index.js';
+import type { OutputFormat, TransformOptions } from './index.js';
 
+class SharpService extends BaseSSRService {
 	async transform(inputBuffer: Buffer, transform: TransformOptions) {
 		const sharpImage = sharp(inputBuffer, { failOnError: false, pages: -1 });
 
diff --git a/packages/integrations/image/src/loaders/squoosh.ts b/packages/integrations/image/src/loaders/squoosh.ts
new file mode 100644
index 000000000..8b3efe4e7
--- /dev/null
+++ b/packages/integrations/image/src/loaders/squoosh.ts
@@ -0,0 +1,134 @@
+// @ts-ignore
+import { ImagePool } from '@squoosh/lib';
+import { red } from 'kleur/colors';
+import { BaseSSRService } from './index.js';
+import { error } from '../utils/logger.js';
+import { metadata } from '../utils/metadata.js';
+import type { OutputFormat, TransformOptions } from './index.js';
+import { isRemoteImage } from '../utils/paths.js';
+
+class SquooshService extends BaseSSRService {
+	/**
+	 * Squoosh doesn't support multithreading when transforming to AVIF files.
+	 * 
+	 * https://github.com/GoogleChromeLabs/squoosh/issues/1111
+	 */
+	#imagePool = new ImagePool(1);
+
+	async processAvif(image: any, transform: TransformOptions) {
+		const encodeOptions = transform.quality
+			? { avif: { quality: transform.quality } }
+			: { avif: {} };
+		await image.encode(encodeOptions);
+		const data = await image.encodedWith.avif;
+
+		return {
+			data: data.binary,
+			format: 'avif' as OutputFormat,
+		};
+	}
+
+	async processJpeg(image: any, transform: TransformOptions) {
+		const encodeOptions = transform.quality
+			? { mozjpeg: { quality: transform.quality } }
+			: { mozjpeg: {} };
+		await image.encode(encodeOptions);
+		const data = await image.encodedWith.mozjpeg;
+
+		return {
+			data: data.binary,
+			format: 'jpeg' as OutputFormat,
+		};
+	}
+
+	async processPng(image: any, transform: TransformOptions) {
+		await image.encode({ oxipng: {} });
+		const data = await image.encodedWith.oxipng;
+
+		return {
+			data: data.binary,
+			format: 'png' as OutputFormat,
+		};
+	}
+
+	async processWebp(image: any, transform: TransformOptions) {
+		const encodeOptions = transform.quality
+			? { webp: { quality: transform.quality } }
+			: { webp: {} };
+		await image.encode(encodeOptions);
+		const data = await image.encodedWith.webp;
+
+		return {
+			data: data.binary,
+			format: 'webp' as OutputFormat,
+		};
+	}
+
+	async autorotate(image: any, transform: TransformOptions, inputBuffer: Buffer) {
+		// check EXIF orientation data and rotate the image if needed
+		const meta = await metadata(transform.src, inputBuffer);
+
+		switch (meta?.orientation) {
+			case 3:
+			case 4:
+				await image.preprocess({ rotate: { numRotations: 2 } });
+				break;
+			case 5:
+			case 6:
+				await image.preprocess({ rotate: { numRotations: 1 } });
+				break;
+			case 7:
+			case 8:
+				await image.preprocess({ rotate: { numRotations: 3 } });
+				break;
+		}
+	}
+
+	async transform(inputBuffer: Buffer, transform: TransformOptions) {
+		const image = this.#imagePool.ingestImage(inputBuffer);
+
+		let preprocessOptions: any = {};
+
+		if (!isRemoteImage(transform.src)) {
+			try {
+				// Image files lie! Rotate the image based on EXIF data
+				await this.autorotate(image, transform, inputBuffer);
+			} catch { }
+		}
+
+		if (transform.width || transform.height) {
+			const width = transform.width && Math.round(transform.width);
+			const height = transform.height && Math.round(transform.height);
+
+			preprocessOptions.resize = {
+				width,
+				height,
+			};
+
+			await image.preprocess({ resize: { width, height } });
+		}
+
+		switch (transform.format) {
+			case 'avif':
+				return await this.processAvif(image, transform);
+			case 'jpg':
+			case 'jpeg':
+				return await this.processJpeg(image, transform);
+			case 'png':
+				return await this.processPng(image, transform);
+			case 'webp':
+				return await this.processWebp(image, transform);
+			default:
+				error({
+					level: 'info',
+					prefix: false,
+					message: red(`Unknown image output: "${transform.format}" used for ${transform.src}`),
+				});
+				throw new Error(`Unknown image output: "${transform.format}" used for ${transform.src}`);
+		}
+	}
+}
+
+const service = new SquooshService();
+
+export default service;
diff --git a/packages/integrations/image/src/utils/metadata.ts b/packages/integrations/image/src/utils/metadata.ts
index 1c3bebdf0..23e83b614 100644
--- a/packages/integrations/image/src/utils/metadata.ts
+++ b/packages/integrations/image/src/utils/metadata.ts
@@ -4,8 +4,12 @@ import { fileURLToPath } from 'node:url';
 import { InputFormat } from '../loaders/index.js';
 import { ImageMetadata } from '../vite-plugin-astro-image.js';
 
-export async function metadata(src: URL): Promise<ImageMetadata | undefined> {
-	const file = await fs.readFile(src);
+export interface Metadata extends ImageMetadata {
+	orientation?: number;
+}
+
+export async function metadata(src: URL | string, data?: Buffer): Promise<Metadata | undefined> {
+	const file = data || await fs.readFile(src);
 
 	const { width, height, type, orientation } = await sizeOf(file);
 	const isPortrait = (orientation || 0) >= 5;
@@ -19,5 +23,6 @@ export async function metadata(src: URL): Promise<ImageMetadata | undefined> {
 		width: isPortrait ? height : width,
 		height: isPortrait ? width : height,
 		format: type as InputFormat,
+		orientation,
 	};
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 49c882959..a221ce052 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2214,6 +2214,7 @@ importers:
 
   packages/integrations/image:
     specifiers:
+      '@squoosh/lib': ^0.4.0
       '@types/sharp': ^0.30.5
       astro: workspace:*
       astro-scripts: workspace:*
@@ -2223,6 +2224,7 @@ importers:
       mime: ^3.0.0
       sharp: ^0.30.6
     dependencies:
+      '@squoosh/lib': 0.4.0
       image-size: 1.0.2
       magic-string: 0.25.9
       mime: 3.0.0
@@ -8700,6 +8702,14 @@ packages:
       - react-dom
     dev: false
 
+  /@squoosh/lib/0.4.0:
+    resolution: {integrity: sha512-O1LyugWLZjMI4JZeZMA5vzfhfPjfMZXH5/HmVkRagP8B70wH3uoR7tjxfGNdSavey357MwL8YJDxbGwBBdHp7Q==}
+    engines: {node: ' ^12.5.0 || ^14.0.0 || ^16.0.0 '}
+    dependencies:
+      wasm-feature-detect: 1.2.11
+      web-streams-polyfill: 3.2.1
+    dev: false
+
   /@surma/rollup-plugin-off-main-thread/2.2.3:
     resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
     dependencies:
@@ -17176,6 +17186,10 @@ packages:
       '@vue/server-renderer': 3.2.38_vue@3.2.38
       '@vue/shared': 3.2.38
 
+  /wasm-feature-detect/1.2.11:
+    resolution: {integrity: sha512-HUqwaodrQGaZgz1lZaNioIkog9tkeEJjrM3eq4aUL04whXOVDRc/o2EGb/8kV0QX411iAYWEqq7fMBmJ6dKS6w==}
+    dev: false
+
   /wcwidth/1.0.1:
     resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
     dependencies: