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;