WIP: adding a service built on @squoosh/lib

This commit is contained in:
Tony Sullivan 2022-08-31 10:49:06 -05:00
parent f018e365cf
commit 51e4a80c3c
8 changed files with 251 additions and 81 deletions

View file

@ -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",

View file

@ -48,6 +48,7 @@ export const get: APIRoute = async ({ request }) => {
},
});
} catch (err: unknown) {
console.error(err);
return new Response(`Server Error: ${err}`, { status: 500 });
}
};

View file

@ -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],

View file

@ -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 }>;
}

View file

@ -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 });

View file

@ -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;

View file

@ -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,
};
}

14
pnpm-lock.yaml generated
View file

@ -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: