Skip to content

Commit b66d8a0

Browse files
authored
fix(cloudflare): default image transform quality to 85 (#2037)
* fix(cloudflare): default image transform quality to 85 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. * 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). * 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.
1 parent acd48da commit b66d8a0

4 files changed

Lines changed: 81 additions & 5 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@emdash-cms/cloudflare": patch
3+
"emdash": patch
4+
---
5+
6+
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.
7+
8+
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).

packages/cloudflare/src/image-endpoint.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
matchInternalMediaKey,
2121
originalMediaHeaders,
2222
parseTransformParams,
23+
resolveTransformQuality,
2324
type ImageTransformFormat,
2425
} from "emdash/media/image-endpoint";
2526

@@ -86,8 +87,14 @@ export const GET: APIRoute = async (ctx) => {
8687
const transform: ImageTransform = {};
8788
if (width) transform.width = width;
8889
if (height) transform.height = height;
90+
// Lossy formats get an explicit quality: the Images binding has no
91+
// default of its own and encodes near-losslessly without one, producing
92+
// renditions several times the size of the original. PNG is exempt —
93+
// an explicit PNG quality switches the binding to lossy PNG8, which is
94+
// not a safe default for a lossless format. An explicit `?q=` always wins.
8995
const output: ImageOutputOptions = { format: outputMime };
90-
if (quality) output.quality = quality;
96+
const effectiveQuality = resolveTransformQuality(format, quality);
97+
if (effectiveQuality !== undefined) output.quality = effectiveQuality;
9198

9299
const result = await images.input(source.body).transform(transform).output(output);
93100
const response = result.response();

packages/core/src/media/image-endpoint.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ export const ALLOWED_TRANSFORM_FORMATS = ["webp", "avif", "jpeg", "png"] as cons
2020
/** Default output format -- broad support, strong compression. */
2121
export const DEFAULT_TRANSFORM_FORMAT: ImageTransformFormat = "webp";
2222

23+
/**
24+
* Default output quality for lossy formats (WebP/AVIF/JPEG) when the request
25+
* doesn't specify one. Matches the default Cloudflare applies to URL-based
26+
* image transformations. The Images *binding* applies no default of its own
27+
* and encodes near-losslessly when quality is omitted (a 2048px WebP comes
28+
* out ~900 KB instead of ~100 KB), so the endpoint sends an explicit quality
29+
* for lossy output. PNG is exempt: an explicit PNG quality switches the
30+
* binding to lossy PNG8, which is not a safe default for a lossless format.
31+
*/
32+
export const DEFAULT_TRANSFORM_QUALITY = 85;
33+
2334
/** Upper bound for a requested dimension; caps the work a single request asks for. */
2435
export const MAX_TRANSFORM_DIMENSION = 4000;
2536

@@ -31,6 +42,11 @@ export interface ImageTransformOptions {
3142
width?: number;
3243
height?: number;
3344
format: ImageTransformFormat;
45+
/**
46+
* Explicitly-requested quality (1-100), or `undefined` when the request
47+
* carried no `q`. Callers apply their own default per format (see
48+
* {@link DEFAULT_TRANSFORM_QUALITY}); lossless PNG deliberately gets none.
49+
*/
3450
quality?: number;
3551
}
3652

@@ -117,10 +133,30 @@ export type ParsedTransformParams =
117133
| { ok: true; options: ImageTransformOptions }
118134
| { ok: false; message: string };
119135

136+
/**
137+
* Resolve the quality to send to the image binding for a transform. An
138+
* explicitly-requested quality always wins. Otherwise lossy formats
139+
* (WebP/AVIF/JPEG) get {@link DEFAULT_TRANSFORM_QUALITY} because the Images
140+
* binding encodes near-losslessly when quality is omitted; lossless PNG gets
141+
* `undefined` because an explicit PNG quality switches the binding to lossy
142+
* PNG8, which is not a safe default for a lossless format.
143+
*/
144+
export function resolveTransformQuality(
145+
format: ImageTransformFormat,
146+
requested: number | undefined,
147+
): number | undefined {
148+
if (requested !== undefined) return requested;
149+
return format === "png" ? undefined : DEFAULT_TRANSFORM_QUALITY;
150+
}
151+
120152
/**
121153
* Parse and validate `?w=&h=&f=&q=` query params. Width is required (it sizes
122154
* the rendition); dimensions are bounded so a request can't ask for an
123-
* unbounded or nonsensical transform.
155+
* unbounded or nonsensical transform. Format falls back to
156+
* {@link DEFAULT_TRANSFORM_FORMAT} when not requested. `q` is validated when
157+
* present but otherwise left `undefined` so the caller can apply a per-format
158+
* default (lossy formats get one, lossless PNG does not — see
159+
* {@link DEFAULT_TRANSFORM_QUALITY}).
124160
*/
125161
export function parseTransformParams(params: URLSearchParams): ParsedTransformParams {
126162
const width = parseDimension(params.get("w"));
@@ -139,8 +175,8 @@ export function parseTransformParams(params: URLSearchParams): ParsedTransformPa
139175
format = formatRaw;
140176
}
141177

142-
const qualityRaw = params.get("q");
143178
let quality: number | undefined;
179+
const qualityRaw = params.get("q");
144180
if (qualityRaw !== null) {
145181
const q = Number(qualityRaw);
146182
if (!Number.isInteger(q) || q < 1 || q > 100) {

packages/core/tests/unit/media/image-endpoint.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import {
44
matchInternalMediaKey,
55
isSafeTransformKey,
66
parseTransformParams,
7+
resolveTransformQuality,
78
isTransformFormat,
89
originalMediaHeaders,
10+
DEFAULT_TRANSFORM_QUALITY,
911
MAX_TRANSFORM_DIMENSION,
1012
} from "../../../src/media/image-endpoint.js";
1113

@@ -94,9 +96,15 @@ describe("parseTransformParams", () => {
9496
});
9597
});
9698

97-
it("defaults format to webp and leaves height/quality undefined", () => {
99+
it("defaults format to webp, leaves quality and height undefined when not requested", () => {
100+
// Quality stays undefined when no `q` is requested: the Cloudflare
101+
// endpoint applies DEFAULT_TRANSFORM_QUALITY for lossy formats itself,
102+
// while lossless PNG must get none (explicit PNG quality → lossy PNG8).
98103
const r = parse("w=800");
99-
expect(r).toEqual({ ok: true, options: { width: 800, format: "webp" } });
104+
expect(r).toEqual({
105+
ok: true,
106+
options: { width: 800, format: "webp", quality: undefined },
107+
});
100108
});
101109

102110
it("rejects out-of-range and non-integer dimensions", () => {
@@ -121,6 +129,23 @@ describe("parseTransformParams", () => {
121129
});
122130
});
123131

132+
describe("resolveTransformQuality", () => {
133+
it("applies the default quality to lossy formats when none is requested", () => {
134+
expect(resolveTransformQuality("webp", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY);
135+
expect(resolveTransformQuality("avif", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY);
136+
expect(resolveTransformQuality("jpeg", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY);
137+
});
138+
139+
it("sends no quality for lossless PNG when none is requested (avoids PNG8)", () => {
140+
expect(resolveTransformQuality("png", undefined)).toBeUndefined();
141+
});
142+
143+
it("honors an explicitly requested quality for every format, including PNG", () => {
144+
expect(resolveTransformQuality("webp", 70)).toBe(70);
145+
expect(resolveTransformQuality("png", 70)).toBe(70);
146+
});
147+
});
148+
124149
describe("originalMediaHeaders", () => {
125150
it("renders safe raster types inline with a sandbox CSP", () => {
126151
const h = originalMediaHeaders("image/png");

0 commit comments

Comments
 (0)