From 35d6ad770886f3ff14b3cc321c1d38fdad4b76df Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:12:37 +0200 Subject: [PATCH 1/3] fix(cloudflare): default image transform quality to 85 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Images binding applies no quality default of its own and encodes near-losslessly when quality is omitted, so renditions without an explicit `q` came out several times larger than the original (a 2048px WebP at ~900 KB instead of ~100 KB with q=85) — defeating the point of the transform. Astro's image service only appends `q` when a quality is explicitly configured, so this was the common path for every EmDash media rendition. parseTransformParams now falls back to DEFAULT_TRANSFORM_QUALITY (85, matching Cloudflare's URL-based image-resizing default) and the Cloudflare endpoint always sends an explicit quality to the binding. --- .changeset/cloudflare-image-quality-default.md | 6 ++++++ packages/cloudflare/src/image-endpoint.ts | 6 ++++-- packages/core/src/media/image-endpoint.ts | 17 ++++++++++++++--- .../tests/unit/media/image-endpoint.test.ts | 11 +++++++++-- 4 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 .changeset/cloudflare-image-quality-default.md diff --git a/.changeset/cloudflare-image-quality-default.md b/.changeset/cloudflare-image-quality-default.md new file mode 100644 index 0000000000..5727b0f553 --- /dev/null +++ b/.changeset/cloudflare-image-quality-default.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/cloudflare": patch +"emdash": patch +--- + +Fixes oversized image renditions on Cloudflare: transforms without an explicit quality were encoded near-losslessly by the Images binding (a 2048px WebP came out ~900 KB instead of ~100 KB). Transforms now default to quality 85, matching Cloudflare's image-resizing default. diff --git a/packages/cloudflare/src/image-endpoint.ts b/packages/cloudflare/src/image-endpoint.ts index 441ecd8d49..8af5f1be1e 100644 --- a/packages/cloudflare/src/image-endpoint.ts +++ b/packages/cloudflare/src/image-endpoint.ts @@ -86,8 +86,10 @@ export const GET: APIRoute = async (ctx) => { const transform: ImageTransform = {}; if (width) transform.width = width; if (height) transform.height = height; - const output: ImageOutputOptions = { format: outputMime }; - if (quality) output.quality = quality; + // Always send a quality: the Images binding has no default and encodes + // near-losslessly without one, producing renditions several times the + // size of the original. + const output: ImageOutputOptions = { format: outputMime, quality }; const result = await images.input(source.body).transform(transform).output(output); const response = result.response(); diff --git a/packages/core/src/media/image-endpoint.ts b/packages/core/src/media/image-endpoint.ts index 69d299565e..b004c91eb6 100644 --- a/packages/core/src/media/image-endpoint.ts +++ b/packages/core/src/media/image-endpoint.ts @@ -20,6 +20,15 @@ export const ALLOWED_TRANSFORM_FORMATS = ["webp", "avif", "jpeg", "png"] as cons /** Default output format -- broad support, strong compression. */ export const DEFAULT_TRANSFORM_FORMAT: ImageTransformFormat = "webp"; +/** + * Default output quality when the request doesn't specify one. Matches the + * default Cloudflare applies to URL-based image transformations. The Images + * *binding* applies no default of its own and encodes near-losslessly when + * quality is omitted (a 2048px WebP comes out ~900 KB instead of ~100 KB), + * so the endpoint must always send an explicit quality. + */ +export const DEFAULT_TRANSFORM_QUALITY = 85; + /** Upper bound for a requested dimension; caps the work a single request asks for. */ export const MAX_TRANSFORM_DIMENSION = 4000; @@ -31,7 +40,7 @@ export interface ImageTransformOptions { width?: number; height?: number; format: ImageTransformFormat; - quality?: number; + quality: number; } /** Long-lived immutable cache -- transform output is deterministic per key+params. */ @@ -120,7 +129,9 @@ export type ParsedTransformParams = /** * Parse and validate `?w=&h=&f=&q=` query params. Width is required (it sizes * the rendition); dimensions are bounded so a request can't ask for an - * unbounded or nonsensical transform. + * unbounded or nonsensical transform. Format and quality fall back to + * {@link DEFAULT_TRANSFORM_FORMAT} / {@link DEFAULT_TRANSFORM_QUALITY} when + * not requested. */ export function parseTransformParams(params: URLSearchParams): ParsedTransformParams { const width = parseDimension(params.get("w")); @@ -140,7 +151,7 @@ export function parseTransformParams(params: URLSearchParams): ParsedTransformPa } const qualityRaw = params.get("q"); - let quality: number | undefined; + let quality = DEFAULT_TRANSFORM_QUALITY; if (qualityRaw !== null) { const q = Number(qualityRaw); if (!Number.isInteger(q) || q < 1 || q > 100) { diff --git a/packages/core/tests/unit/media/image-endpoint.test.ts b/packages/core/tests/unit/media/image-endpoint.test.ts index ac389a8211..eb124ac30e 100644 --- a/packages/core/tests/unit/media/image-endpoint.test.ts +++ b/packages/core/tests/unit/media/image-endpoint.test.ts @@ -6,6 +6,7 @@ import { parseTransformParams, isTransformFormat, originalMediaHeaders, + DEFAULT_TRANSFORM_QUALITY, MAX_TRANSFORM_DIMENSION, } from "../../../src/media/image-endpoint.js"; @@ -94,9 +95,15 @@ describe("parseTransformParams", () => { }); }); - it("defaults format to webp and leaves height/quality undefined", () => { + it("defaults format to webp and quality to the default, leaves height undefined", () => { + // The quality default matters on Cloudflare: the Images binding encodes + // near-losslessly when quality is omitted, so an explicit default must + // always be present. const r = parse("w=800"); - expect(r).toEqual({ ok: true, options: { width: 800, format: "webp" } }); + expect(r).toEqual({ + ok: true, + options: { width: 800, format: "webp", quality: DEFAULT_TRANSFORM_QUALITY }, + }); }); it("rejects out-of-range and non-integer dimensions", () => { From 88c1ea7871c72f9809208d38a5d4251a7a866be1 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:41:10 +0200 Subject: [PATCH 2/3] docs(changeset): note edge-cache purge for image quality fix Previously-served oversized renditions stay cached under the immutable header until they expire; flag the purge step for upgraders (emdashbot). --- .changeset/cloudflare-image-quality-default.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/cloudflare-image-quality-default.md b/.changeset/cloudflare-image-quality-default.md index 5727b0f553..0f3c325e12 100644 --- a/.changeset/cloudflare-image-quality-default.md +++ b/.changeset/cloudflare-image-quality-default.md @@ -4,3 +4,5 @@ --- Fixes oversized image renditions on Cloudflare: transforms without an explicit quality were encoded near-losslessly by the Images binding (a 2048px WebP came out ~900 KB instead of ~100 KB). Transforms now default to quality 85, matching Cloudflare's image-resizing default. + +Note: image responses are cached with `Cache-Control: immutable, max-age=31536000` keyed on the request URL, so previously-served oversized renditions stay cached at the edge/browser until they expire. To benefit immediately after upgrading, purge the Cloudflare cache for your `/_image*` URLs (or the whole site cache). From 59da73afd13095548b918d1598d906b130f75252 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:29:58 +0200 Subject: [PATCH 3/3] refactor(images): exempt PNG from default transform quality Addressing review from khoinguyenpham04: an explicit PNG quality switches the Images binding to lossy PNG8, so applying the lossy default to PNG would silently degrade a lossless format. parseTransformParams now leaves quality undefined unless requested (keeping ImageTransformOptions.quality optional, so the public type is unchanged), and a new resolveTransformQuality helper applies DEFAULT_TRANSFORM_QUALITY only to lossy WebP/AVIF/JPEG while letting an explicit ?q= win for every format including PNG. --- .../cloudflare-image-quality-default.md | 2 +- packages/cloudflare/src/image-endpoint.ts | 13 ++++-- packages/core/src/media/image-endpoint.ts | 45 ++++++++++++++----- .../tests/unit/media/image-endpoint.test.ts | 28 +++++++++--- 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/.changeset/cloudflare-image-quality-default.md b/.changeset/cloudflare-image-quality-default.md index 0f3c325e12..875963dda5 100644 --- a/.changeset/cloudflare-image-quality-default.md +++ b/.changeset/cloudflare-image-quality-default.md @@ -3,6 +3,6 @@ "emdash": patch --- -Fixes oversized image renditions on Cloudflare: transforms without an explicit quality were encoded near-losslessly by the Images binding (a 2048px WebP came out ~900 KB instead of ~100 KB). Transforms now default to quality 85, matching Cloudflare's image-resizing default. +Fixes oversized image renditions on Cloudflare: transforms without an explicit quality were encoded near-losslessly by the Images binding (a 2048px WebP came out ~900 KB instead of ~100 KB). Lossy transforms (WebP/AVIF/JPEG) now default to quality 85, matching Cloudflare's image-resizing default. PNG output keeps no explicit quality — an explicit PNG quality switches the binding to lossy PNG8, which is not a safe default for a lossless format. An explicit `?q=` in the request URL still wins for every format. Note: image responses are cached with `Cache-Control: immutable, max-age=31536000` keyed on the request URL, so previously-served oversized renditions stay cached at the edge/browser until they expire. To benefit immediately after upgrading, purge the Cloudflare cache for your `/_image*` URLs (or the whole site cache). diff --git a/packages/cloudflare/src/image-endpoint.ts b/packages/cloudflare/src/image-endpoint.ts index 8af5f1be1e..c11ea91887 100644 --- a/packages/cloudflare/src/image-endpoint.ts +++ b/packages/cloudflare/src/image-endpoint.ts @@ -20,6 +20,7 @@ import { matchInternalMediaKey, originalMediaHeaders, parseTransformParams, + resolveTransformQuality, type ImageTransformFormat, } from "emdash/media/image-endpoint"; @@ -86,10 +87,14 @@ export const GET: APIRoute = async (ctx) => { const transform: ImageTransform = {}; if (width) transform.width = width; if (height) transform.height = height; - // Always send a quality: the Images binding has no default and encodes - // near-losslessly without one, producing renditions several times the - // size of the original. - const output: ImageOutputOptions = { format: outputMime, quality }; + // Lossy formats get an explicit quality: the Images binding has no + // default of its own and encodes near-losslessly without one, producing + // renditions several times the size of the original. PNG is exempt — + // an explicit PNG quality switches the binding to lossy PNG8, which is + // not a safe default for a lossless format. An explicit `?q=` always wins. + const output: ImageOutputOptions = { format: outputMime }; + const effectiveQuality = resolveTransformQuality(format, quality); + if (effectiveQuality !== undefined) output.quality = effectiveQuality; const result = await images.input(source.body).transform(transform).output(output); const response = result.response(); diff --git a/packages/core/src/media/image-endpoint.ts b/packages/core/src/media/image-endpoint.ts index b004c91eb6..a524fc5eb8 100644 --- a/packages/core/src/media/image-endpoint.ts +++ b/packages/core/src/media/image-endpoint.ts @@ -21,11 +21,13 @@ export const ALLOWED_TRANSFORM_FORMATS = ["webp", "avif", "jpeg", "png"] as cons export const DEFAULT_TRANSFORM_FORMAT: ImageTransformFormat = "webp"; /** - * Default output quality when the request doesn't specify one. Matches the - * default Cloudflare applies to URL-based image transformations. The Images - * *binding* applies no default of its own and encodes near-losslessly when - * quality is omitted (a 2048px WebP comes out ~900 KB instead of ~100 KB), - * so the endpoint must always send an explicit quality. + * Default output quality for lossy formats (WebP/AVIF/JPEG) when the request + * doesn't specify one. Matches the default Cloudflare applies to URL-based + * image transformations. The Images *binding* applies no default of its own + * and encodes near-losslessly when quality is omitted (a 2048px WebP comes + * out ~900 KB instead of ~100 KB), so the endpoint sends an explicit quality + * for lossy output. PNG is exempt: an explicit PNG quality switches the + * binding to lossy PNG8, which is not a safe default for a lossless format. */ export const DEFAULT_TRANSFORM_QUALITY = 85; @@ -40,7 +42,12 @@ export interface ImageTransformOptions { width?: number; height?: number; format: ImageTransformFormat; - quality: number; + /** + * Explicitly-requested quality (1-100), or `undefined` when the request + * carried no `q`. Callers apply their own default per format (see + * {@link DEFAULT_TRANSFORM_QUALITY}); lossless PNG deliberately gets none. + */ + quality?: number; } /** Long-lived immutable cache -- transform output is deterministic per key+params. */ @@ -126,12 +133,30 @@ export type ParsedTransformParams = | { ok: true; options: ImageTransformOptions } | { ok: false; message: string }; +/** + * Resolve the quality to send to the image binding for a transform. An + * explicitly-requested quality always wins. Otherwise lossy formats + * (WebP/AVIF/JPEG) get {@link DEFAULT_TRANSFORM_QUALITY} because the Images + * binding encodes near-losslessly when quality is omitted; lossless PNG gets + * `undefined` because an explicit PNG quality switches the binding to lossy + * PNG8, which is not a safe default for a lossless format. + */ +export function resolveTransformQuality( + format: ImageTransformFormat, + requested: number | undefined, +): number | undefined { + if (requested !== undefined) return requested; + return format === "png" ? undefined : DEFAULT_TRANSFORM_QUALITY; +} + /** * Parse and validate `?w=&h=&f=&q=` query params. Width is required (it sizes * the rendition); dimensions are bounded so a request can't ask for an - * unbounded or nonsensical transform. Format and quality fall back to - * {@link DEFAULT_TRANSFORM_FORMAT} / {@link DEFAULT_TRANSFORM_QUALITY} when - * not requested. + * unbounded or nonsensical transform. Format falls back to + * {@link DEFAULT_TRANSFORM_FORMAT} when not requested. `q` is validated when + * present but otherwise left `undefined` so the caller can apply a per-format + * default (lossy formats get one, lossless PNG does not — see + * {@link DEFAULT_TRANSFORM_QUALITY}). */ export function parseTransformParams(params: URLSearchParams): ParsedTransformParams { const width = parseDimension(params.get("w")); @@ -150,8 +175,8 @@ export function parseTransformParams(params: URLSearchParams): ParsedTransformPa format = formatRaw; } + let quality: number | undefined; const qualityRaw = params.get("q"); - let quality = DEFAULT_TRANSFORM_QUALITY; if (qualityRaw !== null) { const q = Number(qualityRaw); if (!Number.isInteger(q) || q < 1 || q > 100) { diff --git a/packages/core/tests/unit/media/image-endpoint.test.ts b/packages/core/tests/unit/media/image-endpoint.test.ts index eb124ac30e..f6d567d38b 100644 --- a/packages/core/tests/unit/media/image-endpoint.test.ts +++ b/packages/core/tests/unit/media/image-endpoint.test.ts @@ -4,6 +4,7 @@ import { matchInternalMediaKey, isSafeTransformKey, parseTransformParams, + resolveTransformQuality, isTransformFormat, originalMediaHeaders, DEFAULT_TRANSFORM_QUALITY, @@ -95,14 +96,14 @@ describe("parseTransformParams", () => { }); }); - it("defaults format to webp and quality to the default, leaves height undefined", () => { - // The quality default matters on Cloudflare: the Images binding encodes - // near-losslessly when quality is omitted, so an explicit default must - // always be present. + it("defaults format to webp, leaves quality and height undefined when not requested", () => { + // Quality stays undefined when no `q` is requested: the Cloudflare + // endpoint applies DEFAULT_TRANSFORM_QUALITY for lossy formats itself, + // while lossless PNG must get none (explicit PNG quality → lossy PNG8). const r = parse("w=800"); expect(r).toEqual({ ok: true, - options: { width: 800, format: "webp", quality: DEFAULT_TRANSFORM_QUALITY }, + options: { width: 800, format: "webp", quality: undefined }, }); }); @@ -128,6 +129,23 @@ describe("parseTransformParams", () => { }); }); +describe("resolveTransformQuality", () => { + it("applies the default quality to lossy formats when none is requested", () => { + expect(resolveTransformQuality("webp", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY); + expect(resolveTransformQuality("avif", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY); + expect(resolveTransformQuality("jpeg", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY); + }); + + it("sends no quality for lossless PNG when none is requested (avoids PNG8)", () => { + expect(resolveTransformQuality("png", undefined)).toBeUndefined(); + }); + + it("honors an explicitly requested quality for every format, including PNG", () => { + expect(resolveTransformQuality("webp", 70)).toBe(70); + expect(resolveTransformQuality("png", 70)).toBe(70); + }); +}); + describe("originalMediaHeaders", () => { it("renders safe raster types inline with a sandbox CSP", () => { const h = originalMediaHeaders("image/png");