diff --git a/.changeset/cloudflare-image-quality-default.md b/.changeset/cloudflare-image-quality-default.md new file mode 100644 index 0000000000..875963dda5 --- /dev/null +++ b/.changeset/cloudflare-image-quality-default.md @@ -0,0 +1,8 @@ +--- +"@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). 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 441ecd8d49..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,8 +87,14 @@ export const GET: APIRoute = async (ctx) => { const transform: ImageTransform = {}; if (width) transform.width = width; if (height) transform.height = height; + // 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 }; - if (quality) output.quality = quality; + 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 69d299565e..a524fc5eb8 100644 --- a/packages/core/src/media/image-endpoint.ts +++ b/packages/core/src/media/image-endpoint.ts @@ -20,6 +20,17 @@ 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 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; + /** Upper bound for a requested dimension; caps the work a single request asks for. */ export const MAX_TRANSFORM_DIMENSION = 4000; @@ -31,6 +42,11 @@ export interface ImageTransformOptions { width?: number; height?: number; format: ImageTransformFormat; + /** + * 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; } @@ -117,10 +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. + * 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")); @@ -139,8 +175,8 @@ export function parseTransformParams(params: URLSearchParams): ParsedTransformPa format = formatRaw; } - const qualityRaw = params.get("q"); let quality: number | undefined; + const qualityRaw = params.get("q"); 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..f6d567d38b 100644 --- a/packages/core/tests/unit/media/image-endpoint.test.ts +++ b/packages/core/tests/unit/media/image-endpoint.test.ts @@ -4,8 +4,10 @@ import { matchInternalMediaKey, isSafeTransformKey, parseTransformParams, + resolveTransformQuality, isTransformFormat, originalMediaHeaders, + DEFAULT_TRANSFORM_QUALITY, MAX_TRANSFORM_DIMENSION, } from "../../../src/media/image-endpoint.js"; @@ -94,9 +96,15 @@ describe("parseTransformParams", () => { }); }); - it("defaults format to webp and leaves height/quality undefined", () => { + 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" } }); + expect(r).toEqual({ + ok: true, + options: { width: 800, format: "webp", quality: undefined }, + }); }); it("rejects out-of-range and non-integer dimensions", () => { @@ -121,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");