Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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': patch
---

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 @@ -120,21 +205,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