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: