Skip to content

Evidence Image Encoding: WebP by default

Screenshot and mockup evidence (.planning/phases/**/mockups/screenshots/, docs/ui-specs/**) commits as WebP, not PNG/GIF, as of OBJ-2385. This page covers the tooling, the CI gate that keeps it that way, and what to do when a capture doesn't fit the default path.

Why WebP

~770 MB of committed screenshot/mockup evidence was uncompressed PNG/GIF — 61% of the repo tree at the time. Measured against this repo's real corpus:

  • PNG → WebP stills: cwebp -q 85 -m 4 saves 68.9%, at a quality no reviewer can distinguish from lossless on flat-region, sharp-text screenshot content.
  • GIF → animated WebP: gif2webp -q 75 -m 4 -mixed saves 77.9%, while keeping the property GIF was chosen for in the first place — GitHub and the Multica issue view render it inline, no click-to-download. A .webm attachment doesn't have that property; WebM stays what it already was, the raw source preserved next to a handful of GIFs for full fidelity. See scripts/lib/motion-gif.mjs's header for the full GIF-vs-WebM rationale this supersedes.

Producers

Two scripts write the committed evidence corpus and both route through the shared encoder helper automatically — no per-call opt-in:

  • ionic_frontend/scripts/desi-render.mjs (Desi's render_mockup tool — stills + --motion)
  • scripts/capture-mockups.mjs (mockup screenshot capture)

Both capture PNG/GIF first (Playwright's own output format), encode to WebP, and delete the intermediate raster on success. If the encoder isn't available or the encode fails, the PNG/GIF is kept and the script reports why (encode_skipped in desi-render.mjs's JSON output) — evidence that exists beats evidence that's small, so a missing cwebp never blocks a capture run, it just leaves you with the older format.

scripts/capture-ui-evidence.mjs is deliberately not wired to this — it writes to gitignored .planning/ui-evidence*, contributes zero committed bytes, and its PNG output feeds check-png-legibility.mjs. Converting it would blind a gate that exists because of the OBJ-1521/OBJ-1595 blank-evidence incidents.

Install libwebp

The encoder is libwebp's cwebp/gif2webp binaries — not ffmpeg, not an npm package. This repo already has ffmpeg for GIF/WebM motion evidence, but its homebrew build has no libwebp encoder compiled in (Automatic encoder selection failed ... codec webp is probably disabled), so it can't do this encode. sharp/imagemin are out too — the repo has no npm image-processing dependency anywhere, a deliberate constraint stated in both check-png-legibility.mjs and motion-gif.mjs.

bash
# macOS (local dev / agent runtime)
brew install webp

# CI (ubuntu-latest)
sudo apt-get update && sudo apt-get install -y webp

This installs cwebp, dwebp, and gif2webp. No npm dependency added to any package.json.

Manual encode commands

Route new captures through desi-render.mjs / capture-mockups.mjs where possible — they call these with the pinned settings automatically. To encode a file by hand (e.g. converting an existing screenshot outside those scripts):

bash
# Still (PNG -> WebP)
cwebp -q 85 -m 4 -metadata none input.png -o output.webp

# Animation (GIF -> animated WebP)
gif2webp -q 75 -m 4 -mixed input.gif -o output.webp

Both quality/method values are pinned in scripts/lib/webp-encode.mjs's ENCODE_DEFAULTS — measured against this repo's real corpus, not defaults. Lossless was also measured (42% saved vs. 68.9% for -q 85) and rejected: 2.5x the bytes for a fidelity difference nobody reviewing a screenshot can see.

The CI gate

scripts/check-image-size.mjs fails a PR if any changed image file exceeds 400 KB, unless it's under a carved-out path or explicitly allowlisted. It's changed-files-only — modeled on scripts/check-pr-scope.mjs — so it never looks at unchanged files, which is what makes it safe to ship before the existing corpus is backfilled.

Why 400 KB, not the more obvious 200 KB: measured against the projected post-conversion corpus (n=1,791), 200 KB trips 18.3% of files — routine full-page captures, a gate that would get routed around within a month. 400 KB trips 5.4%, the genuine outliers.

Carved-out paths (never gated)

Native asset catalogs and format-locked app assets — not evidence images, and re-encoding them breaks a build or a live consumer:

  • ionic_frontend/ios/**, ionic_frontend/android/** — native asset catalogs
  • ionic_frontend/public/**, guide_site/public/**, marketing_landing/images/** — favicons, PWA icons, contrast-checker-consumed assets
  • rails_api/spec/** — test fixtures

Adding an allowlist entry

If a specific file must stay oversized (see the 16383px limit below — this is currently the only reason to add one), add it to scripts/image-size-allowlist.json by exact path:

json
{
  "path": "path/to/the/file.png",
  "issue": "OBJ-XXXX",
  "reason": "why this file can't be re-encoded or shrunk"
}

The gate self-tests the allowlist on every run — an entry pointing at a file that no longer exists fails the check (a stale exemption is a silent gate hole, not a harmless leftover).

Size budget

desi-render.mjs's --max-bytes flag (default: the gate's own DEFAULT_THRESHOLD_BYTES, imported from scripts/check-image-size.mjs so the renderer and the gate can't drift) makes a gate-compliant capture the default render path instead of a manual cwebp trick (OBJ-3375). It's a byte budget, not a target: attempt 1 always runs at the pinned ENCODE_DEFAULTS (byte-identical to the no-budget path), and escalation to cwebp -size target mode only fires when that attempt comes back over budget — so the vast majority of captures, which were never near the gate, cost nothing extra.

The ladder, capped at 3 total cwebp invocations per still:

  1. Attempt 1 — the pinned default, -q 85 -m 4 -metadata none. If the result fits the budget, done.
  2. Attempt 2 (only if attempt 1 is over budget) — re-encode from the original PNG, never the attempt-1 WebP (re-compressing an already-lossy WebP would stack a second lossy generation onto evidence whose only job is legibility): cwebp -size <target> -m 6 -pass 10 -metadata none, where target is 95% of the budget — -size is best-effort and can overshoot, so attempt 2 aims under the ceiling rather than at it.
  3. Attempt 3 (only if attempt 2 is still over) — one more -size retry, with target scaled down by the observed overshoot.

If the still is still over budget after all 3 attempts, encodeStill keeps the smallest WebP produced and reports overBudget: true — that's a report, not a thrown failure, consistent with this file's "evidence that exists beats evidence that's small" contract. desi-render.mjs's JSON output gains a bytes field per screenshots[] entry and a size_budget array listing any capture where escalation engaged (mode, attempts, final bytes, whether it's still over).

bash
# Default — budgets to the CI gate's own 400 KB threshold
node scripts/desi-render.mjs mockup.html

# Explicit budget (bytes)
node scripts/desi-render.mjs mockup.html --max-bytes=200000

# Disable budgeting entirely (escape hatch for a deliberate one-off)
node scripts/desi-render.mjs mockup.html --max-bytes=0
# "off" is equivalent to 0
node scripts/desi-render.mjs mockup.html --max-bytes=off

Motion clips (--motion) are never re-encoded to fit — gif2webp has no -size target mode. An over-budget clip is folded into the motion_skipped warning instead of a re-encode attempt.

Implementation: scripts/lib/webp-encode.mjs's encodeStill / escalateToBudget / parseMaxBytes.

Fixing a violation

The gate's failure message names the file, its size, the threshold, and a fix command that now leads with the in-pipeline route:

re-render via 'desi-render.mjs --max-bytes=409600' (the default) so the byte-budget escalation in scripts/lib/webp-encode.mjs targets this gate directly; by hand: cwebp -size 389120 -m 6 -pass 10 -metadata none <source> -o <file>  (a flat -q rarely clears this gate — see OBJ-3375)

Before OBJ-3375 the printed remedy was cwebp -q 80 -m 6 -metadata none — a lower quality / higher method than the default -q 85 -m 4. That remedy is measurably insufficient: Desi measured it at 443 KB against the 400 KB gate for a text-heavy board, and every neighbouring knob made it worse (-f 0 → 443 KB, -preset text → 473 KB, -sharp_yuv → 461 KB). Only plain quality moved these files at all, and target-size mode moved them best — six captures from 457–514 KB down to 365–368 KB, at higher per-file quality than a flat -q 75 would have given, because each file gets its own quality instead of the worst file's.

If a capture still doesn't fit after the 3-attempt ladder above, split it (e.g. don't full-page a very tall page) rather than dropping quality further — with a bounded ladder, that guidance is now the load-bearing fallback, not a footnote.

The 16383px dimension limit

WebP has a hard format limit: both dimensions must fit in 14 bits (16383px). A small number of full-page captures in this repo exceed it — encodeStill() detects this from the PNG header and refuses to spawn the encoder, returning a named reason instead of crashing. These stay PNG and are allowlisted (see scripts/image-size-allowlist.json for the current list — full-page mobile/desktop captures up to 780×37402 as of OBJ-2385).

If a new capture hits this limit: there's no re-encode option for it. Either accept the PNG and add an allowlist entry (with the issue that justifies it), or split the capture into shorter sections that fit under 16383px.

Not converted

.planning/ui-evidence* is gitignored — it never enters a commit, so it isn't bloat, and staying PNG is what lets check-png-legibility.mjs keep working unchanged. Native app assets (ionic_frontend/ios/**, ionic_frontend/android/**) stay PNG — non-negotiable, native platforms require it.

See also

Last updated: 2026-09-10 (OBJ-3375: size-budget targeting + --max-bytes, corrected formatFixCommand remedy)

Loading…