Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix(media): stop serving multi-MB originals to retina screens — varia…
…nts-only srcset

Lighthouse kept reporting huge PNG downloads despite a full WebP srcset.
Root cause: buildMediaSrcset appended the ORIGINAL file as the largest
srcset candidate, and the variant ladder capped at 2048w. With
sizes="1280px", any display with DPR >= 1.7 needs > 2048 device px, so
the browser's only candidate big enough was the original — a 7.4 MB PNG
in the reported case (122 KB as its 2048w WebP). Lighthouse mobile
emulates DPR 2.625, so it selected the PNG on every image, every run.

The fix, in three parts:

- srcset is built from VARIANTS ONLY — the original never appears as a
  candidate. Applied to the publisher (buildMediaSrcset) and to the
  admin twin (buildVariantSrcset: editor canvas, media viewer, video
  poster preview), whose fallthrough now also prefers the largest
  variant over the original. The original survives in `src` only, which
  width-descriptor srcsets reserve for non-srcset browsers.

- the variant ladder gains one rung AT the source's intrinsic width, so
  the srcset's top candidate is a full-quality WebP and dropping the
  original costs no quality ceiling. The rung is clamped to WebP's hard
  16383px output cap (a 900x17000 screenshot encodes a clamped rung
  instead of failing the whole job), and images smaller than every
  target width keep zero variants — small pixel-art icons still publish
  as pixel-exact plain `src`, never force-re-encoded to lossy WebP. The
  Tier-3 delegate path intentionally emits declared widths only: the
  host must not synthesize URLs outside the plugin's schema-bounded
  (16..8192) widths contract.

- lazy images with the default sizes='auto' now emit
  `sizes="auto, <ancestor-cap-or-100vw>"` — Chrome 121+ selects by the
  image's actual rendered width (grids/cards stop over-fetching);
  everyone else parses past the unknown keyword to the fallback. The
  spec restricts `auto` to lazy images, so eager images emit the
  fallback alone, and author-supplied sizes stay verbatim.

Verified live end-to-end: published markup is WebP-only srcset topped by
the intrinsic rung with sizes="auto, 100vw", and a DPR-2 browser at
1280px viewport downloaded the 20 KB intrinsic WebP — the PNG was never
requested. Existing assets (uploaded before this change) have no
intrinsic rung; their srcset now tops at the 2048w WebP, which is still
strictly better than the original on every display. Re-upload to get the
full-resolution top rung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • Loading branch information
DavidBabinec and claude committed Jun 11, 2026
commit f88f142a8144e154c99f2bdcb689ba4d6b796e51
4 changes: 4 additions & 0 deletions docs/features/media.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ media_assets row created, variants_json populated for raster sources

SVG uploads are sanitized and stored as originals only. GIF uploads also stay original-only so animation is preserved; the responsive WebP ladder is generated only for JPEG, PNG, and WebP uploads.

The ladder encodes one WebP per target width (64 / 320 / 640 / 1024 / 1600 / 2048) **below** the source's intrinsic width — never upscaled — plus one rung **at** the intrinsic width. That top rung exists so `srcset` can be built from variants alone: the original file (often a multi-MB PNG) never appears as a srcset candidate, because a high-DPI display asking for more pixels than the largest sub-intrinsic rung would otherwise select it (`sizes="1280px"` on a 2x screen requests 2560 device px). `buildMediaSrcset` (publisher) and `buildVariantSrcset` (admin surfaces) both enforce the variants-only rule at render time, and lazy images emit `sizes="auto, <fallback>"` so Chromium-based browsers select by the actual rendered width.

Ladder edge rules: the intrinsic rung is clamped so neither output dimension exceeds WebP's hard 16383px cap (a 900×17000 screenshot gets a clamped top rung instead of a failed job); images smaller than every target width get **no variants** and publish as plain pixel-exact `src` (small icons are never force-re-encoded to lossy WebP). The Tier-3 delegate path emits **declared widths only** — the host never synthesizes an intrinsic-width URL the delegate's allowlist might reject; the largest declared sub-intrinsic width is that ladder's ceiling.

The image-variant worker pool is sized by `IMAGE_VARIANT_WORKER_POOL_SIZE` (default 2, hard cap 8). Workers are spawned lazily on first use and reused for the life of the process; a crashed worker is dropped from the pool and a replacement spawns on the next submission.

Defense in depth on the static path: `hardenUploadResponse` in `server/static.ts` adds `X-Content-Type-Options: nosniff` and `Content-Disposition: attachment` for non-inert MIME types so a stray non-allowlisted upload can't be top-level navigated and rendered as HTML on the admin origin.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/module-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ interface RenderResolvedMedia {
}
```

`buildMediaSrcset(media)` returns a `srcset` string sorted by ascending width with the original appended, or `null` when no variants exist. `pickMediaVariantUrl(media, targetWidth)` returns the smallest variant ≥ `targetWidth` (safe URL-escaped).
`buildMediaSrcset(media)` returns a `srcset` string of the variants sorted by ascending width, or `null` when no variants exist. The original file is never included — any srcset candidate is selectable, and the original may be a multi-MB unoptimized source; the ladder's intrinsic-width WebP rung is the full-quality ceiling instead. `pickMediaVariantUrl(media, targetWidth)` returns the smallest variant ≥ `targetWidth` (safe URL-escaped).

---

Expand Down
31 changes: 28 additions & 3 deletions server/handlers/cms/imageVariantWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
*
* 1. Probe intrinsic dimensions via `sharp`.
* 2. Encode a BlurHash placeholder from a downsampled RGBA buffer.
* 3. (Optional) Generate one WebP per configured target width.
* 3. (Optional) Generate one WebP per configured target width below the
* intrinsic width, plus one at the intrinsic width (the srcset's
* full-quality top rung).
*
* Everything else — DB lookups, delegate election, storage-adapter
* dispatch — stays on the main thread. The worker has no DB client and
Expand All @@ -20,6 +22,9 @@ import { encode as encodeBlurHash } from 'blurhash'
import type { ImageVariantJobRequest, ImageVariantJobResponse, ImageVariantPayload } from './imageVariantProtocol'
import { toArrayBuffer } from '../../binary'

/** libwebp's hard cap on either output dimension. */
const MAX_WEBP_DIMENSION = 16383

function send(msg: ImageVariantJobResponse, transfer: ArrayBuffer[] = []): void {
;(self as unknown as { postMessage: (m: unknown, transfer?: ArrayBuffer[]) => void }).postMessage(msg, transfer)
}
Expand Down Expand Up @@ -72,8 +77,28 @@ async function handleJob(req: ImageVariantJobRequest): Promise<void> {
const variants: ImageVariantPayload[] = []
const transfer: ArrayBuffer[] = []
if (req.generateLadder) {
for (const width of req.targetWidths) {
if (width >= originalWidth) continue
// Target rungs below the intrinsic width (never upscale), topped by
// one rung AT the intrinsic width: the srcset's largest candidate is
// then a full-quality WebP, so the renderer never needs the original
// (potentially a multi-MB PNG) as a high-DPI fallback.
//
// The WebP encoder refuses either OUTPUT dimension above 16383px, so
// the ladder is clamped to the largest width whose aspect-scaled
// height still fits — a 900x17000 screenshot encodes a clamped top
// rung instead of throwing (which would kill the whole job and strip
// the asset of variants, dimensions, AND BlurHash).
//
// Images smaller than every target width get NO variants at all: they
// publish as plain pixel-exact `src` (a 48px pixel-art icon must not
// be force-re-encoded to lossy WebP for zero byte savings).
const maxSafeWidth = Math.min(
MAX_WEBP_DIMENSION,
Math.floor((MAX_WEBP_DIMENSION * originalWidth) / originalHeight),
)
const intrinsicRung = Math.min(originalWidth, maxSafeWidth)
const subRungs = req.targetWidths.filter((w) => w < intrinsicRung)
const widths = subRungs.length ? [...subRungs, intrinsicRung] : []
for (const width of widths) {
const v = await sharp(bytes)
.resize({ width, withoutEnlargement: true })
.webp({ quality: req.webpQuality })
Expand Down
12 changes: 11 additions & 1 deletion server/handlers/cms/mediaVariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import { toArrayBuffer } from '../../binary'
* Target widths for the responsive variant ladder. Chosen to cover the
* common breakpoints we serve in the editor (mobile 375 / tablet 768 /
* desktop 1024 / wide 1600+) plus a tiny 64 the admin grid uses for
* fast-loading thumbnails, plus a 2048 high-DPI variant.
* fast-loading thumbnails, plus a 2048 high-DPI variant. The worker also
* encodes one rung at the source's INTRINSIC width, so the srcset's top
* candidate is always a full-quality WebP and the original never has to
* appear in srcset (see buildMediaSrcset).
*
* Sorted ascending so the variant array stays small-to-large for callers
* doing a linear "smallest >= target" pick.
Expand Down Expand Up @@ -253,6 +256,13 @@ function buildDelegateVariants(
const format = delegate.formats[0] ?? 'webp'
const path = originPathForDelegate(parentStoragePath)
const out: MediaVariantRecord[] = []
// Declared widths below intrinsic ONLY — no host-synthesized intrinsic
// rung here, unlike the local worker. The plugin's `widths` array is a
// contract ("widths the renderer should emit in srcset", schema-bounded
// to 16..8192): minting an undeclared probed width could exceed the
// service's allowlist or output cap and put a broken URL at the TOP of
// the srcset. The original is excluded from srcset by buildMediaSrcset
// regardless, so the largest declared rung is the delegate's ceiling.
for (const width of delegate.widths) {
if (width >= originalWidth) continue
const url = fillDelegateTemplate(delegate.variantUrlTemplate, {
Expand Down
2 changes: 1 addition & 1 deletion server/repositories/mediaMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export interface MigrationBacklog {
* Computed JS-side from `variants_json` because the value is a JSON blob
* (no per-variant DB row exists). For sites with thousands of assets the
* JS pass is still fast — variants per asset are bounded by the
* `TARGET_WIDTHS` ladder (≤ 6).
* `TARGET_WIDTHS` ladder plus the intrinsic rung (≤ 7).
*/
variants: number
}
Expand Down
13 changes: 11 additions & 2 deletions src/__tests__/module-engine/moduleConsolidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,21 @@ function makeMedia(overrides: Partial<RenderResolvedMedia> = {}): RenderResolved
// ---------------------------------------------------------------------------

describe('F3 buildMediaSrcset / pickMediaVariantUrl', () => {
it('builds an ascending srcset and appends the original at full width', () => {
it('builds an ascending srcset from the variants ONLY — never the original', () => {
// The original is excluded deliberately: it may be a multi-MB PNG, and
// any srcset candidate is selectable (a 1280px slot on a 2x display asks
// for 2560px — if the original tops the ladder, every retina visitor
// downloads it). The ladder's top rung is the intrinsic-width WebP.
expect(buildMediaSrcset(makeMedia())).toBe(
'/uploads/hero-w320.webp 320w, /uploads/hero-w640.webp 640w, /uploads/hero.webp 1200w',
'/uploads/hero-w320.webp 320w, /uploads/hero-w640.webp 640w',
)
})

it('never includes the original even when it is the only large candidate', () => {
const srcset = buildMediaSrcset(makeMedia({ publicPath: '/uploads/hero.png', width: 2688 }))
expect(srcset).not.toContain('.png')
})

it('returns null when there are no variants', () => {
expect(buildMediaSrcset(makeMedia({ variants: [] }))).toBeNull()
})
Expand Down
79 changes: 79 additions & 0 deletions src/__tests__/modules/imageResponsiveAttrs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* base.image responsive attributes — `sizes` resolution.
*
* `sizes='auto'` (the prop default) must emit the standards-based
* `auto, <fallback>` form on LAZY images: browsers that implement
* `sizes=auto` (Chrome 121+) pick by the image's actual rendered width,
* everyone else parses past the unknown keyword and uses the fallback.
* The spec only allows `auto` on `loading="lazy"` images, so eager images
* emit the fallback alone. Author-supplied `sizes` values are verbatim.
*/
import { describe, expect, it } from 'bun:test'
import type { RenderResolvedMedia } from '@core/publisher'
import { registry } from '@core/module-engine'

import '@modules/base'

function media(): RenderResolvedMedia {
return {
publicPath: '/uploads/hero.png',
mimeType: 'image/png',
width: 2688,
height: 1520,
altText: '',
blurHash: null,
posterPath: null,
variants: [
{ width: 640, height: 362, format: 'webp', path: '/uploads/hero-w640.webp', sizeBytes: 100 },
{ width: 1024, height: 579, format: 'webp', path: '/uploads/hero-w1024.webp', sizeBytes: 200 },
],
}
}

function renderImage(props: Record<string, unknown>): string {
const img = registry.getOrThrow('base.image')
return img.render(
{
src: '/uploads/hero.png',
fetchPriority: 'auto',
decoding: 'async',
_resolvedMediaByKey: { src: media() },
...props,
},
[],
).html
}

function sizesAttr(html: string): string | null {
const m = html.match(/sizes="([^"]*)"/)
return m ? m[1] : null
}

describe('base.image sizes resolution', () => {
it("lazy + sizes 'auto' with a publisher-resolved cap emits `auto, <cap>`", () => {
const html = renderImage({ loading: 'lazy', sizes: 'auto', _resolvedAutoSizes: '1280px' })
expect(sizesAttr(html)).toBe('auto, 1280px')
})

it("lazy + sizes 'auto' without a resolved cap emits `auto, 100vw`", () => {
const html = renderImage({ loading: 'lazy', sizes: 'auto' })
expect(sizesAttr(html)).toBe('auto, 100vw')
})

it("eager + sizes 'auto' emits the fallback alone (`auto` keyword is lazy-only)", () => {
const html = renderImage({ loading: 'eager', sizes: 'auto', _resolvedAutoSizes: '1280px' })
expect(sizesAttr(html)).toBe('1280px')
})

it('an author-supplied sizes value is emitted verbatim', () => {
const html = renderImage({ loading: 'lazy', sizes: '(min-width: 1024px) 50vw, 100vw' })
expect(sizesAttr(html)).toBe('(min-width: 1024px) 50vw, 100vw')
})

it('srcset never contains the original file', () => {
const html = renderImage({ loading: 'lazy', sizes: 'auto' })
const m = html.match(/srcset="([^"]*)"/)
expect(m).not.toBeNull()
expect(m![1]).not.toContain('.png')
})
})
68 changes: 65 additions & 3 deletions src/__tests__/server/imageVariantWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,78 @@ describe('image-variant worker pool', () => {
// encoder isn't deterministic across libvips versions for the same
// input, but length + non-empty is enough to gate the round-trip.
expect(response.blurHash.length).toBeGreaterThan(10)
// 64, 320, 640 are < 800; 1024 is skipped (>= source width). So 3
// variants come back, in ascending order.
expect(response.variants.map((v) => v.width)).toEqual([64, 320, 640])
// 64, 320, 640 are < 800; 1024 is skipped (>= source width); a final
// rung is encoded at the source's intrinsic width so the srcset's top
// candidate is a full-quality WebP — the renderer never has to fall
// back to the (potentially multi-MB) original for high-DPI displays.
expect(response.variants.map((v) => v.width)).toEqual([64, 320, 640, 800])
const intrinsic = response.variants[response.variants.length - 1]
expect(intrinsic.height).toBe(400)
for (const variant of response.variants) {
expect(variant.bytes.byteLength).toBeGreaterThan(0)
// Each variant is a fresh ArrayBuffer the host now owns.
expect(variant.bytes).toBeInstanceOf(ArrayBuffer)
}
})

it('emits exactly one rung when the source width equals a target width', async () => {
const bytes = await fixturePng(640, 320)
const response = await runImageVariantJob({
bytes,
generateLadder: true,
targetWidths: [64, 320, 640, 1024],
webpQuality: 80,
blurhashConfig: { x: 4, y: 3, sampleWidth: 32, sampleHeight: 32 },
})

expect(isImageVariantOk(response)).toBe(true)
if (!isImageVariantOk(response)) return
// 640 is skipped as a target (>= source) but comes back once as the
// intrinsic rung — never duplicated.
expect(response.variants.map((v) => v.width)).toEqual([64, 320, 640])
})

it('clamps the intrinsic rung to the WebP 16383px dimension cap (tall screenshot)', async () => {
// A 100x17000 strip: sub-intrinsic rungs encode fine, but a WebP at the
// full intrinsic size is impossible (height > 16383). The rung must be
// clamped — one impossible rung must never kill the whole job.
const bytes = await fixturePng(100, 17000)
const response = await runImageVariantJob({
bytes,
generateLadder: true,
targetWidths: [64, 320, 640, 1024],
webpQuality: 80,
blurhashConfig: { x: 4, y: 3, sampleWidth: 32, sampleHeight: 32 },
})

expect(isImageVariantOk(response)).toBe(true)
if (!isImageVariantOk(response)) return
expect(response.width).toBe(100)
expect(response.height).toBe(17000)
expect(response.blurHash.length).toBeGreaterThan(10)
// floor(16383 * 100 / 17000) = 96 — the largest width whose scaled
// height still fits the encoder.
expect(response.variants.map((v) => v.width)).toEqual([64, 96])
expect(response.variants[1].height).toBeLessThanOrEqual(16383)
})

it('emits NO variants for an image smaller than every target width', async () => {
// A 40px icon publishes as plain pixel-exact `src` — force-re-encoding
// it to lossy WebP would smear pixel art for zero byte savings.
const bytes = await fixturePng(40, 20)
const response = await runImageVariantJob({
bytes,
generateLadder: true,
targetWidths: [64, 320, 640],
webpQuality: 80,
blurhashConfig: { x: 4, y: 3, sampleWidth: 32, sampleHeight: 32 },
})

expect(isImageVariantOk(response)).toBe(true)
if (!isImageVariantOk(response)) return
expect(response.variants).toEqual([])
})

it('skips the ladder when generateLadder is false (delegate path)', async () => {
const bytes = await fixturePng(800, 400)
const response = await runImageVariantJob({
Expand Down
45 changes: 45 additions & 0 deletions src/admin/pages/media/utils/__tests__/variants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Admin variant helpers — same selection policy as the publisher's
* `buildMediaSrcset` / `pickMediaVariantUrl` (variants only, never the
* original): the editor canvas and media viewer must not download a
* multi-MB original on high-DPI displays any more than a published page.
*/
import { describe, expect, it } from 'bun:test'
import { buildVariantSrcset, pickVariantUrl } from '../variants'

const asset = {
publicPath: '/uploads/hero.png',
width: 2688,
variants: [
{ width: 640, height: 362, format: 'webp', path: '/uploads/hero-w640.webp', sizeBytes: 100 },
{ width: 2688, height: 1520, format: 'webp', path: '/uploads/hero-w2688.webp', sizeBytes: 400 },
],
}

describe('buildVariantSrcset', () => {
it('emits the variants only — never the original', () => {
expect(buildVariantSrcset(asset)).toBe(
'/uploads/hero-w640.webp 640w, /uploads/hero-w2688.webp 2688w',
)
})

it('returns undefined with no variants', () => {
expect(buildVariantSrcset({ ...asset, variants: [] })).toBeUndefined()
})
})

describe('pickVariantUrl', () => {
it('picks the smallest variant at or above the target', () => {
expect(pickVariantUrl(asset, 500)).toBe('/uploads/hero-w640.webp')
})

it('falls back to the LARGEST VARIANT, not the original, when nothing is big enough', () => {
// Legacy assets cap at 2048w; a 4K target must get the largest WebP —
// marginally lower resolution beats a multi-MB PNG download.
expect(pickVariantUrl(asset, 9999)).toBe('/uploads/hero-w2688.webp')
})

it('returns the original only when the asset has no variants at all', () => {
expect(pickVariantUrl({ ...asset, variants: [] }, 500)).toBe('/uploads/hero.png')
})
})
Loading