Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/calm-coats-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'astro': minor
---

Adds codec-specific defaults for Astro's built-in Sharp image service via `image.service.config`.

You can now configure encoder-level options such as `jpeg.mozjpeg`, `webp.effort`, `webp.alphaQuality`, `avif.effort`, `avif.chromaSubsampling`, and `png.compressionLevel` when using `astro/assets/services/sharp` for compile-time image generation.

These settings apply as defaults for the built-in Sharp pipeline, while per-image `quality` still takes precedence when set on `<Image />`, `<Picture />`, or `getImage()`.
111 changes: 98 additions & 13 deletions packages/astro/src/assets/services/sharp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import type { FitEnum, FormatEnum, ResizeOptions, SharpOptions } from 'sharp';
import type {
AvifOptions,
FitEnum,
FormatEnum,
JpegOptions,
PngOptions,
ResizeOptions,
SharpOptions,
WebpOptions,
} from 'sharp';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import type { ImageFit, ImageOutputFormat, ImageQualityPreset } from '../types.js';
import {
Expand All @@ -18,6 +27,26 @@ export interface SharpImageServiceConfig {
* The `kernel` option is passed to resize calls. See https://sharp.pixelplumbing.com/api-resize/ for more information
*/
kernel?: ResizeOptions['kernel'];

/**
* Default encoder options passed to `sharp().jpeg()`.
*/
jpeg?: JpegOptions;

/**
* Default encoder options passed to `sharp().png()`.
*/
png?: PngOptions;

/**
* Default encoder options passed to `sharp().webp()`.
*/
webp?: WebpOptions;

/**
* Default encoder options passed to `sharp().avif()`.
*/
avif?: AvifOptions;
}

let sharp: typeof import('sharp');
Expand All @@ -29,6 +58,62 @@ const qualityTable: Record<ImageQualityPreset, number> = {
max: 100,
};

function resolveSharpQuality(quality: BaseServiceTransform['quality']): number | undefined {
if (!quality) return undefined;

const parsedQuality = parseQuality(quality);
if (typeof parsedQuality === 'number') {
return parsedQuality;
}

return quality in qualityTable ? qualityTable[quality] : undefined;
}

export function resolveSharpEncoderOptions(
transform: Pick<BaseServiceTransform, 'format' | 'quality'>,
inputFormat: string | undefined,
serviceConfig: SharpImageServiceConfig = {},
):
| JpegOptions
| PngOptions
| WebpOptions
| AvifOptions
| { quality?: number }
| undefined {
const quality = resolveSharpQuality(transform.quality);

switch (transform.format) {
case 'jpg':
case 'jpeg':
return {
...serviceConfig.jpeg,
...(quality === undefined ? {} : { quality }),
};
case 'png':
return {
...serviceConfig.png,
...(quality === undefined ? {} : { quality }),
};
case 'webp': {
const webpOptions: WebpOptions = {
...serviceConfig.webp,
...(quality === undefined ? {} : { quality }),
};
if (inputFormat === 'gif') {
webpOptions.loop ??= 0;
}
return webpOptions;
}
case 'avif':
return {
...serviceConfig.avif,
...(quality === undefined ? {} : { quality }),
};
default:
return quality === undefined ? undefined : { quality };
}
}

async function loadSharp() {
let sharpImport: typeof import('sharp');
try {
Expand Down Expand Up @@ -116,21 +201,21 @@ const sharpService: LocalImageService<SharpImageServiceConfig> = {
}

if (transform.format) {
let quality: number | string | undefined = undefined;
if (transform.quality) {
const parsedQuality = parseQuality(transform.quality);
if (typeof parsedQuality === 'number') {
quality = parsedQuality;
} else {
quality = transform.quality in qualityTable ? qualityTable[transform.quality] : undefined;
}
}
const encoderOptions = resolveSharpEncoderOptions(transform, format, config.service.config);

if (transform.format === 'webp' && format === 'gif') {
// Convert animated GIF to animated WebP with loop=0 (infinite)
result.webp({ quality: typeof quality === 'number' ? quality : undefined, loop: 0 });
// Convert animated GIF to animated WebP with loop=0 (infinite) unless overridden in config.
result.webp(encoderOptions as WebpOptions | undefined);
} else if (transform.format === 'webp') {
result.webp(encoderOptions as WebpOptions | undefined);
} else if (transform.format === 'png') {
result.png(encoderOptions as PngOptions | undefined);
} else if (transform.format === 'avif') {
result.avif(encoderOptions as AvifOptions | undefined);
} else if (transform.format === 'jpeg' || transform.format === 'jpg') {
result.jpeg(encoderOptions as JpegOptions | undefined);
} else {
result.toFormat(transform.format as keyof FormatEnum, { quality });
result.toFormat(transform.format as keyof FormatEnum, encoderOptions);
}
}

Expand Down
63 changes: 63 additions & 0 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1695,6 +1695,13 @@ export interface AstroUserConfig<
* entrypoint: 'astro/assets/services/sharp',
* config: {
* limitInputPixels: false,
* webp: {
* effort: 6,
* alphaQuality: 80,
* },
* jpeg: {
* mozjpeg: true,
* },
* },
* },
* },
Expand Down Expand Up @@ -1730,6 +1737,62 @@ export interface AstroUserConfig<
* By default this is `undefined`, which maps to Sharp's default kernel of `lanczos3`.
*/

/**
* @docs
* @name image.service.config.jpeg
* @kind h4
* @type {Record<string, any> | undefined}
* @version 6.0.0
* @description
*
* Default encoder options passed to `sharp().jpeg()` when using Astro's built-in Sharp image service.
*
* This can be used for options such as `mozjpeg`, `progressive`, `chromaSubsampling`, or a default `quality`.
* Per-image `quality` values from `<Image />`, `<Picture />`, and `getImage()` still take precedence.
*/

/**
* @docs
* @name image.service.config.webp
* @kind h4
* @type {Record<string, any> | undefined}
* @version 6.0.0
* @description
*
* Default encoder options passed to `sharp().webp()` when using Astro's built-in Sharp image service.
*
* This can be used for options such as `effort`, `alphaQuality`, `lossless`, `nearLossless`, or a default `quality`.
* Per-image `quality` values from `<Image />`, `<Picture />`, and `getImage()` still take precedence.
*/

/**
* @docs
* @name image.service.config.avif
* @kind h4
* @type {Record<string, any> | undefined}
* @version 6.0.0
* @description
*
* Default encoder options passed to `sharp().avif()` when using Astro's built-in Sharp image service.
*
* This can be used for options such as `effort`, `chromaSubsampling`, `bitdepth`, `lossless`, or a default `quality`.
* Per-image `quality` values from `<Image />`, `<Picture />`, and `getImage()` still take precedence.
*/

/**
* @docs
* @name image.service.config.png
* @kind h4
* @type {Record<string, any> | undefined}
* @version 6.0.0
* @description
*
* Default encoder options passed to `sharp().png()` when using Astro's built-in Sharp image service.
*
* This can be used for options such as `compressionLevel`, `effort`, `palette`, or a default `quality`.
* Per-image `quality` values from `<Image />`, `<Picture />`, and `getImage()` still take precedence.
*/

/**
* @docs
* @name image.domains
Expand Down
98 changes: 98 additions & 0 deletions packages/astro/test/units/assets/image-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,104 @@ const FIXTURE_IMAGE = new URL('./600x400.jpg', import.meta.url);
const ORIGINAL_WIDTH = 600;
const ORIGINAL_HEIGHT = 400;

describe('sharp encoder options', async () => {
const { resolveSharpEncoderOptions } = await import('../../../dist/assets/services/sharp.js');

it('uses codec-specific config defaults when no transform quality is provided', () => {
assert.deepEqual(
resolveSharpEncoderOptions(
{ format: 'webp' },
undefined,
{
webp: {
effort: 6,
alphaQuality: 80,
quality: 72,
},
},
),
{
effort: 6,
alphaQuality: 80,
quality: 72,
},
);
});

it('prefers transform quality over config quality', () => {
assert.deepEqual(
resolveSharpEncoderOptions(
{ format: 'avif', quality: '70' },
undefined,
{
avif: {
effort: 9,
quality: 50,
},
},
),
{
effort: 9,
quality: 70,
},
);
});

it('maps jpg output to jpeg encoder defaults', () => {
assert.deepEqual(
resolveSharpEncoderOptions(
{ format: 'jpg' },
undefined,
{
jpeg: {
mozjpeg: true,
chromaSubsampling: '4:2:0',
},
},
),
{
mozjpeg: true,
chromaSubsampling: '4:2:0',
},
);
});

it('keeps animated gif webp loop default unless config overrides it', () => {
assert.deepEqual(
resolveSharpEncoderOptions(
{ format: 'webp' },
'gif',
{
webp: {
effort: 5,
},
},
),
{
effort: 5,
loop: 0,
},
);

assert.deepEqual(
resolveSharpEncoderOptions(
{ format: 'webp' },
'gif',
{
webp: {
effort: 5,
loop: 2,
},
},
),
{
effort: 5,
loop: 2,
},
);
});
});

describe('sharp image service', async () => {
const sharpService = (await import('../../../dist/assets/services/sharp.js')).default;

Expand Down
Loading