WIP: adding a service built on @squoosh/lib
This commit is contained in:
parent
f018e365cf
commit
51e4a80c3c
8 changed files with 251 additions and 81 deletions
packages/integrations/image
pnpm-lock.yaml
|
@ -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",
|
||||
|
|
|
@ -48,6 +48,7 @@ export const get: APIRoute = async ({ request }) => {
|
|||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
return new Response(`Server Error: ${err}`, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
134
packages/integrations/image/src/loaders/squoosh.ts
Normal file
134
packages/integrations/image/src/loaders/squoosh.ts
Normal 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;
|
|
@ -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
14
pnpm-lock.yaml
generated
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue