diff --git a/.changeset/calm-coats-approve.md b/.changeset/calm-coats-approve.md new file mode 100644 index 000000000000..4c3ffe6f1a81 --- /dev/null +++ b/.changeset/calm-coats-approve.md @@ -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 ``, ``, or `getImage()`. diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts index 66d64032725d..99cfe13ac187 100644 --- a/packages/astro/src/assets/services/sharp.ts +++ b/packages/astro/src/assets/services/sharp.ts @@ -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 { @@ -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'); @@ -29,6 +58,62 @@ const qualityTable: Record = { 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, + 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 { @@ -116,21 +201,21 @@ const sharpService: LocalImageService = { } 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); } } diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 37f522264566..2b4b81e1fb04 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -1695,6 +1695,13 @@ export interface AstroUserConfig< * entrypoint: 'astro/assets/services/sharp', * config: { * limitInputPixels: false, + * webp: { + * effort: 6, + * alphaQuality: 80, + * }, + * jpeg: { + * mozjpeg: true, + * }, * }, * }, * }, @@ -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 | 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 ``, ``, and `getImage()` still take precedence. + */ + + /** + * @docs + * @name image.service.config.webp + * @kind h4 + * @type {Record | 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 ``, ``, and `getImage()` still take precedence. + */ + + /** + * @docs + * @name image.service.config.avif + * @kind h4 + * @type {Record | 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 ``, ``, and `getImage()` still take precedence. + */ + + /** + * @docs + * @name image.service.config.png + * @kind h4 + * @type {Record | 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 ``, ``, and `getImage()` still take precedence. + */ + /** * @docs * @name image.domains diff --git a/packages/astro/test/units/assets/image-service.test.js b/packages/astro/test/units/assets/image-service.test.js index 61e4702cdf82..a6164dc2b3aa 100644 --- a/packages/astro/test/units/assets/image-service.test.js +++ b/packages/astro/test/units/assets/image-service.test.js @@ -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;