Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use imagetools for more image processing options #608

Merged
merged 6 commits into from
Jun 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/wet-wolves-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@responsive-image/webpack': major
---

Use imagetools for more image processing options

`@responsive-image/webpack` is now using the `imagetools-core` package for image processing via `sharp`. This now supports not only scaling to different sizes and generating different image formats as before, but also a lot of other [directives](https://github.com/JonasKruckenberg/imagetools/blob/main/docs/directives.md) for image manipulation.

_Breaking Changes_: Some parameters passed to the loader as defaults directly or using as query parameters in imports had to change to align with that library:

- `widths` has been renamed to `w`
- `formats` to `format`
- the separator for array vlues has been changed to `;` instead of `,`

Example: `import image from './path/to/image.jpg?w=400;800&responsive';`
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Again, when following our conventional setup, we need the `responsive` query par
In addition to that, we can also pass query params that affect the actual image processing:

```js
import heroImage from './hero.jpg?lqip=inline&widths=1920,1280,640&responsive';
import heroImage from './hero.jpg?lqip=inline&w=1920;1280;640&responsive';
```

In this case we are processing this image for only this specific import with different image options than the defaults, as we generate the image variants with specific widths and opt into a [Low Quality Image Placeholder](#lqip) technique of `inline`. This applies only to the image data you get back from this specific import, but does not affect any of the other images or even the same image but with different or just the default image options imported elsewhere!
Expand All @@ -200,7 +200,7 @@ In a template you can use the `<ResponsiveImage/>` component. The `@src` argumen
Note that with components with separate `.js` and `.hbs` files, you would need to assign the image data to the backing component class, so you can access it in your template as in this case as `this.heroImage`:

```js
import heroImage from './hero.jpg?lqip=inline&widths=1920,1280,640&responsive';
import heroImage from './hero.jpg?lqip=inline&w=1920;1280;640&responsive';

export default class HeroImageComponent extends Component {
heroImage = heroImage;
Expand All @@ -210,7 +210,7 @@ export default class HeroImageComponent extends Component {
With [`<template>` tag](https://github.com/ember-template-imports/ember-template-imports) and `.gjs` (`.gts`) components, this becomes much easier:

```gjs
import heroImage from './hero.jpg?lqip=inline&widths=1920,1280,640&responsive';
import heroImage from './hero.jpg?lqip=inline&w=1920;1280;640&responsive';

<template>
<ResponsiveImage @src={{heroImage}} />
Expand Down Expand Up @@ -285,7 +285,7 @@ img {
But this addon also supports a _fixed_ layout with fixed image dimensions. Just provide either `@width` or `@height` to opt into that mode. Also make sure that the generated image variants have the appropriate sizes:

```gjs
import logoImage from './hero.jpg?lqip=inline&widths=320,640&responsive';
import logoImage from './hero.jpg?lqip=inline&w=320;640&responsive';

<ResponsiveImage @src={{logoImage}} @width={{320}} />
```
Expand Down
10 changes: 5 additions & 5 deletions packages/webpack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ The package comes with reasonable defaults, but if you want to customize these f

```js
setupLoaders({
widths: [1024, 2048],
formats: ['original', 'avif'],
w: [1024, 2048],
format: ['original', 'avif'],
});
```

Expand All @@ -59,7 +59,7 @@ setupLoaders({
Besides globa settings, you can also pass all the supported configuration options as query parameters when importimng an image:

```js
import logo from './logo.jpg?responsive&widths=32,64&quality=95';
import logo from './logo.jpg?responsive&w=32;64&quality=95';
```

Query params alwas take precedence of global settings passed to `setupLoaders()`.
Expand All @@ -68,8 +68,8 @@ Query params alwas take precedence of global settings passed to `setupLoaders()`

| option | type | description | default |
| ------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `widths` | `Array<number>` | The image widths to be generated. For responsive images this should match the typical device sizes, eventually taking account when the image is not covering the full screen size, like `50vw`. For fixed size images this should be the intended size and twice of it for 2x displays. Pass this as a comma separated list when using query params. | `[640, 750, 828, 1080, 1200, 1920, 2048, 3840]` |
| `formats` | `Array<'original'\|'png'\|'jpeg'\|'webp'\|'avif'>` | The image formats to generate. `original` refers to whatever the original image's type is. Pass this as a comma separated list when using query params. | `['original', 'webp']` |
| `w` | `Array<number>` | The image widths to be generated. For responsive images this should match the typical device sizes, eventually taking account when the image is not covering the full screen size, like `50vw`. For fixed size images this should be the intended size and twice of it for 2x displays. Pass this as a comma separated list when using query params. | `[640, 750, 828, 1080, 1200, 1920, 2048, 3840]` |
| `format` | `Array<'original'\|'png'\|'jpeg'\|'webp'\|'avif'>` | The image formats to generate. `original` refers to whatever the original image's type is. Pass this as a comma separated list when using query params. | `['original', 'webp']` |
| `quality` | `number` | The image quality (0 - 100). | 80 |
| `name` | `string` | The template for the generated image files. Certains placeholders like `[ext]` and `[width]` and all the common Webpack placeholders are replaced with real values. | [name]-[width]w-[hash].[ext] |
| `webPath` | `string` | The public URL the emitted files are referenced from. By default, this matches Webpacks public URL and the path generated from `outputPath`. |
Expand Down
1 change: 1 addition & 0 deletions packages/webpack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"@responsive-image/core": "workspace:^",
"base-n": "^3.0.0",
"blurhash": "^2.0.4",
"imagetools-core": "^7.0.0",
"loader-utils": "^3.2.0",
"sharp": "^0.33.0"
},
Expand Down
57 changes: 32 additions & 25 deletions packages/webpack/src/images.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getOptions, normalizeInput } from './utils';
import type { ImageType } from '@responsive-image/core';
import { ImageConfig } from 'imagetools-core';
import type { Metadata, Sharp } from 'sharp';
import type { LoaderContext } from 'webpack';
import type {
Expand All @@ -8,6 +8,7 @@ import type {
LoaderOptions,
OutputImageType,
} from './types';
import { getImagetoolsConfigs, getOptions, normalizeInput } from './utils';

const supportedTypes: ImageType[] = ['png', 'jpeg', 'webp', 'avif'];

Expand Down Expand Up @@ -37,10 +38,15 @@ async function process(
try {
const sharpMeta = await sharp.metadata();

const formats = effectiveImageFormats(options.formats, sharpMeta);
const { widths, quality } = options;
const format = effectiveImageFormats(options.format, sharpMeta);
const configs = await getImagetoolsConfigs({
...options,
format,
});

const images = await generateResizedImages(sharp, widths, formats, quality);
const images = await Promise.all(
configs.map((config) => generateResizedImage(sharp, config)),
);

return {
sharpMeta,
Expand All @@ -57,29 +63,30 @@ async function process(
// receive input as Buffer
imagesLoader.raw = true;

async function generateResizedImages(
async function generateResizedImage(
image: Sharp,
widths: number[],
formats: ImageType[],
quality: number,
): Promise<ImageProcessingResult[]> {
return Promise.all(
widths.flatMap((width) => {
const resizedImage = image.clone();
resizedImage.resize(width, null, { withoutEnlargement: true });

return formats.map(async (format) => {
const data = await resizedImage
.toFormat(format, { quality })
.toBuffer();
return {
data,
width,
format,
};
});
}),
config: ImageConfig,
): Promise<ImageProcessingResult> {
const imagetools = await import('imagetools-core');

const { transforms } = imagetools.generateTransforms(
config,
imagetools.builtins,
new URLSearchParams(),
);

const { image: resizedImage, metadata } = await imagetools.applyTransforms(
transforms,
image,
);

const data = await resizedImage.toBuffer();

return {
data,
width: metadata.width!,
format: metadata.format as ImageType,
};
}

function effectiveImageFormats(
Expand Down
13 changes: 9 additions & 4 deletions packages/webpack/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,20 @@ export type LqipLoaderOptions =
| LqipInlineLoaderOptions
| LqipBlurhashLoaderOptions;

export interface LoaderOptions {
widths: number[];
formats: OutputImageType[];
quality: number;
export interface WebpackLoaderOptions {
name: string;
webPath?: string;
outputPath: string;
lqip?: LqipLoaderOptions;
}
export interface ImageLoaderOptions {
w: number[];
quality: number;
format: OutputImageType[];
[key: string]: unknown;
}

export type LoaderOptions = WebpackLoaderOptions & ImageLoaderOptions;

export interface ImageProcessingResult {
data: Buffer;
Expand Down
55 changes: 46 additions & 9 deletions packages/webpack/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
import baseN from 'base-n';
import sharp, { type Metadata } from 'sharp';
import type { LoaderContext } from 'webpack';
import type { ImageLoaderChainedResult, LoaderOptions } from './types';
import type {
ImageLoaderChainedResult,
LoaderOptions,
WebpackLoaderOptions,
} from './types';
import type { ImageConfig } from 'imagetools-core';

const b64 = baseN.create();

const defaultImageConfig: LoaderOptions = {
quality: 80,
widths: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
formats: ['original', 'webp'],
w: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
// TODO: remove this, needs fixing tests
allowUpscale: 'true',
format: ['original', 'webp'],
name: '[name]-[width]w-[hash].[ext]',
outputPath: 'images',
};

export function parseQuery(query: string): Record<string, unknown> {
const webpackLoaderKeys: string[] = [
'lqip',
'name',
'outputPath',
'webPath',
] satisfies Array<keyof WebpackLoaderOptions>;
const queryArraySeparator = ';';

export function parseQuery(query: string): Partial<LoaderOptions> {
const params = new URLSearchParams(query);
return Object.fromEntries(
[...params.entries()].map(([key, value]) => {
switch (key) {
case 'widths':
return [key, value.split(',').map((v) => parseInt(v, 10))];
case 'formats':
return [key, value.split(',')];
case 'w':
return [
key,
value.split(queryArraySeparator).map((v) => parseInt(v, 10)),
];
case 'format':
return [key, value.split(queryArraySeparator)];
case 'quality':
return [key, parseInt(value, 10)];
case 'lqip': {
Expand All @@ -43,6 +61,25 @@ export function parseQuery(query: string): Record<string, unknown> {
);
}

export async function getImagetoolsConfigs(
options: LoaderOptions,
): Promise<ImageConfig[]> {
const imagetools = await import('imagetools-core');

const entries = Object.entries(options)
.filter(([key]) => !webpackLoaderKeys.includes(key))
.map(([key, value]) => {
// imagetools expects this type
const stringarrayifiedValue = Array.isArray(value)
? value.map((v) => String(v))
: [String(value)];

return [key, stringarrayifiedValue] satisfies [string, string[]];
});

return imagetools.resolveConfigs(entries, imagetools.builtinOutputFormats);
}

export function getOptions(
context: LoaderContext<Partial<LoaderOptions>>,
): LoaderOptions {
Expand All @@ -53,7 +90,7 @@ export function getOptions(
...defaultImageConfig,
...context.getOptions(),
...parsedResourceQuery,
};
} as LoaderOptions;
}

export function normalizeInput(
Expand Down
Loading