Skip to content

RFC-030 — Responsive image delivery + weight reduction (mobile · web · Google TV)

Status · Partially implemented (Slices 0–3 + WebP-only shipped, #383; Slice 4 auto-derive invariant pending) — decision locked in ADR-080 Date · 2026-07-08 Gates · #383 (tracking) · #379 (image weight) · evolves ADR-079 Slice C (masters store + WebP + derivatives) · mobile epic #149 · Google TV target Precondition · git-LFS masters store — nothing destructive lands until it exists (D0) Supersedes/extends · ADR-079 D2 (source/derived) + D3 (WebP delivery) — this is the concrete delivery-size design Slice C was deferring to

Why this is an RFC. Cutting image weight touches the whole delivery spine at once: how three clients with a 3× display spread (mobile / web / 4K Google TV) each ask for the right size, whether that's srcset or an image server, where the permanent originals live (git-LFS masters) vs the churning derivatives, how the pipeline re-derives on every image change so churn can't re-bloat, and — surfaced during Slice 1b — how a bulk re-encode coexists with the hand-curated byte-dupe / phash image-authorization system that keys on derived bytes. Those are architecture questions across the crop pipeline, the components, validate-data, and the deploy; this RFC locks the answers before the slices so they don't re-litigate mid-flight.

Gating sentence: display images ship WebP-only as a size ladder — an unsuffixed base NN.webp (the canonical, capped ≤ 3072 px q80) plus smaller width-suffixed NN-<w>.webp rungs — that DOM <img> selects via srcset/sizes (viewport × DPR, so phone/desktop/4K-TV each fetch the size they render) while 3D textures select by quality-tier, all URLs resolving through the assetUrl spine; every full-resolution original is kept permanently in the git-LFS masters/ store (so any rung / format regenerates losslessly), and the ladder is derived through the pipeline — hotspots/ (zoomable 3D tiers) and posters/ (downloadable art) stay full-res JPEG. (The auto-derive validate-data invariant that would fail preflight on an oversized/ladder-incomplete image is Slice 4, pending.)

Shipped result (2026-07-08): deploy image tree ~1.9 GB → ~897 MB; masters (~1 GB) in git-LFS ($0 tier). The journey went cap → additive WebP ladder → WebP-only (jpg dropped); the ~4% ancient-web-browser gap is accepted (ADR-079, all app targets are WebP-native).


Goal

Two coupled outcomes:

  1. Weight — cut the served image bulk (build/images ~1.9 GB → ~750 MB; deploy ~2.2 GB → ~1.1 GB) so the VPS deploy and the Capacitor mobile CDN stream are light.
  2. Per-client sizing — each of the three targets fetches a size appropriate to its display, with no per-platform build flag and no image server:
    • mobile ~1280 px (streamed over cellular),
    • web ~1440–2560 px,
    • Google TV (40–50″ = 4K 3840×2160; a 90vw lightbox ≈ 3456 px) ~3072 px.

And — because images churn constantly — the whole thing must be self-maintaining in the workflow, not a manual pass.

Non-goals

  • An on-the-fly image server (imgproxy / Cloudflare Images) — see D1 alternatives; reconsider only if the ladder becomes unwieldy.
  • Re-sizing the zoomable hotspots/ 3D surface tiers (they keep their existing tier system).
  • A separate WebP slice — WebP (ADR-079 D3) is folded directly into the ladder derivation (the rungs are encoded as WebP), not deferred.

Survey — measured current state (2026-07-08)

build/images ≈ 1.9 GB. Two populations plus one exclusion:

PopulationSizeFilesStatus
1×1 thumbnails~76 MB (was 653)2,523Done — Slice 0 (crop-variants 512 px cap + in-place migration this cycle)
Full-size (hero / gallery / lightbox)1,049 MB3,067this RFC
hotspots/ (3D surface tier2/tier3 pan/zoom)176 MB232excluded — genuinely need resolution

Key measurements:

  • The full-size tail is broken: gallery photos at 5000–6720 px / 7.5 MB, displayed in a fit-to-viewport lightbox (PanelLightbox: max-width:90vw; object-fit:contain; cursor:zoom-out) — no pinch-zoom. So resolution beyond viewport is pure waste.

  • The weight is quality, not pixels. Capping non-hotspot images at various sizes + q85 mozjpeg (dry-run over 2,835 files):

    CapQualitySavesFiles actually resized
    2048 pxq82435 MB301
    2560 pxq85348 MB158
    3072 pxq85326 MB69
    3840 pxq85312 MB40

    Going all the way to native 4K (3840) costs only 123 MB vs the aggressive 2048 cap — because almost all the win is re-encoding bloated-quality JPEGs (saved at q90–95+), not downsizing. TV resolution is therefore NOT in tension with size.

Decisions

D0 — Masters are permanent; everything served is a regenerable derivative

Invariant (never relaxed): the full-resolution original of every image is kept forever in a non-served masters/ store, never mutated. Every file in static/images/ — thumbnail, ladder rung, WebP — is a derivative that can be regenerated from its master at any time, losslessly. So any future change is cheap: a new rung, a higher-quality re-encode, WebP→AVIF, a different crop — all re-derive from masters, never from an already-lossy served copy.

This RFC de-facto executes ADR-079's deferred Slice C (masters store + derivatives), and git-LFS is the hard precondition — masters (~1–2 GB of originals) live in LFS, out of main git; pointers only in the tree. Nothing destructive lands until LFS + masters/ exist (Slice 1a), because today static/images is the only copy — capping in place first would forfeit the very regenerability this invariant exists to guarantee. (Slice 0 needed no master: it touched only derived 1×1 crops, leaving the plain .jpg intact.)

Split: only masters → LFS. The derived ladder stays in the served static/images/ tree so the deploy serves it directly (ADR-079: CI validates + deploys, never encodes — derivatives must be pre-committed). Open question 6 revisits whether derived should also move to LFS now that GH Pages is retired and the VPS can LFS-pull on deploy — attractive because derived binaries churn and otherwise grow main-git history unbounded.

D1 — Pre-generated size ladder + srcset (not an on-the-fly image server)

Delivery <img> images carry a srcset of pre-generated widths; the browser asks for the right one per client:

html
<!-- WebP-only; the largest rung is the unsuffixed base NN.webp (also the src). -->
<img
  srcset="…/NN-1280.webp 1280w, …/NN-2048.webp 2048w, …/NN.webp 3072w"
  sizes="90vw"
  src="…/NN.webp" alt="…" />

The browser picks the smallest candidate satisfying viewport × DPR — phone → 1280, desktop → 2048, 4K-TV lightbox (3840 CSS px, DPR 1, 90vw ≈ 3456) → 3072. This is "ask for a size per client," done natively — no platform detection, and mobile streams the small candidate automatically.

Why not an image server (imgproxy / thumbor / Cloudflare Images serving ?w=): the app is static-origin (VPS nginx + Capacitor streaming from a static CDN). srcset is browser-native, composes with the assetUrl spine + SW caching, and needs no service / CPU / cache infra. An image server doesn't fit Capacitor's stream-from-static-origin model without making the CDN itself that server. Deferred, not chosen.

D2 — Served-derivative policy: cap the largest rung at 3840 px + q85 mozjpeg

The original master (in masters/, D0) keeps its full resolution. The largest served rung is capped at 3840 px (native 4K — trims only the ~40 absurd >4K files, nothing a 50″ panel can show) and re-encoded q85 mozjpeg (the visually-lossless floor; q85 on a hero at 3840 px is imperceptible — gated by a visual sign-off on a TV-sized viewport, Slice 1). This is −312 MB with full TV quality, and because the original is retained it is fully reversible.

D3 — Client selects by viewport, never by build-flag

srcset / sizes on DOM <img>; the browser (mobile WebView / desktop / TV WebView) chooses by viewport × DPR. No __MOBILE__ / __TV__ branching for images — a build flag is too coarse (it can't tell a phone-on-web from a 4K monitor, and TV-as-Capacitor-Android would collide with mobile's flag). Viewport handles every gradation.

D4 — 3D / programmatic textures select by quality-tier

srcset can't reach TextureLoader. Surface / hotspot skybox textures pick their resolution at runtime by quality-tier — the existing pattern (the 4k/2k gate under __MOBILE__, ADR-079). <img> → srcset; textures → tier-pick. Two mechanisms, both already have a home.

D5 — Exclude hotspots/ and posters/ from the ladder

Two categories stay full-res JPEG (excluded from the WebP ladder + the cap, in both build-display-ladder.mjs and cap-display-images.mjs, and skipped by the provenance re-key):

  • hotspots/ — the tier2/tier3 pan/zoom panoramas keep full resolution + their tier system.
  • posters/ — downloadable art; the /posters page links them for download, so a capped WebP would degrade the download and JPEG is more universally openable + print-friendly (decided 2026-07-08).

The ladder + cap apply only to lightbox/hero/gallery display images.

D6 — Origin through the assetUrl spine

Every srcset candidate resolves through assetUrl() (ADR-079 D1) → CDN-absolute under __MOBILE__, local otherwise. The SW caches whichever size the browser fetched. No new streaming logic.

D7 — Automatic derivation + a validate-data invariant (the churn guarantee)

Masters and the ladder are source vs derived (ADR-079): the committed master is the source; the ladder (1280/2048/3072) + 512 thumbnail are derived, generated by the pipeline (crop-variants / images:score), cache-keyed so unchanged images are free.

  • On ingest (/dev/staging promote + fetch-assets): cap the master (3840/q85) and derive the ladder + thumbnail automatically — no oversized image enters the shipped tree.
  • validate-data invariant (new gate): every shipped delivery image ≤ 3840 px, every master has its complete, fresh ladder + thumbnail, and thumbnails ≤ 512 px. Fails preflight otherwise — a manually-dropped or swapped image can't ship un-resized. Fix = one npm run images:derive.
  • Churn: add → derived; replace → cache-key miss → re-derived + the gate catches staleness; delete → derived pruned (orphan check). This is what makes the size win permanent instead of a one-off that decays as missions/fleet grow.

The size ladder

RungWidthConsumerRationale
thumbnail512 px1×1 cards / mobile rows≤140 px display × DPR3 (done, Slice 0)
display-s1280 pxmobile lightbox/hero (streamed)~400 CSS px × DPR3; light over cellular
display-m2048 pxdesktop / laptop≤1440p retina lightbox
display-l3072 px4K TV / 4K monitor90vw of 3840 ≈ 3456 → 3072 nearest
original (master)full resnot served — source in masters/ (D0)regenerate any rung / WebP / AVIF losslessly

The interim (Slice 1b, before the ladder exists) serves a single image capped at 3840 px per slot; Slice 2–3 replace that with the srcset ladder above. Exact rungs are an open question — 1536 could be added; keep the ladder short to bound file-count.

sizes-per-component contract (load-bearing)

The one thing that must be right — a wrong sizes makes the browser over/under-fetch:

Componentsizes
PanelHeroImage (detail hero)100vw
PanelLightbox (full view)90vw
gallery card / fleet row140px (→ thumbnail, already 1×1)
mission/fleet grid tileminmax(96–140px) → its rendered width

A lint/test flags any delivery <img> without a sizes attribute (Slice 3).

Slice plan

  • Slice 0 — thumbnails · DONE. crop-variants capped at 512 px + in-place migration (−533 MB, committed this cycle). Safe without masters — only touched derived 1×1 crops.

  • Slice 1a — git-LFS masters store + $0-cost config (the precondition · ADR-079 Slice C). In order:

    1. git lfs install; git lfs track "masters/**" (→ .gitattributes).
    2. Commit .lfsconfig with fetchexclude = masters/** — makes skip-masters the default for every clone/CI/VPS checkout, so LFS bandwidth stays inside the free tier (~$0/mo, RFC §cost). The audit (2026-07-08) confirmed CI is already safe (all actions/checkout default lfs: false; the static deploy rsyncs build/, never git-LFS); .lfsconfig makes that structural + covers the VPS-side git fetch+reset checkout.
    3. Copy the current full-res static/images originals into masters/ (LFS-tracked), preserving structure — the permanent originals, before any derivative is capped.
    4. Convention: the only thing that fetches masters is the local encode step, explicitly git lfs pull --include='masters/**'; nothing else ever pulls them.

    Must precede 1b — it's the only non-lossy path, and the D0 invariant depends on it.

  • Slice 1b — served cap + backfill. Cap non-hotspot served images at 3840/q85, derived from masters (−312 MB). Immediate deploy win, no delivery change yet. Gated by a hero visual sign-off on a 3840 px viewport.

  • Slice 2 — derive the ladder, as WebP. Extend crop-variants to emit the 1280/2048/3072 rungs encoded WebP (ADR-079 D3 folded in here, not deferred) next to each master; wire into images:score + promote (D7). Stacks ~30 % on top of the cap.

  • Slice 3 — wire srcset. PanelHeroImage, PanelLightbox, gallery/grid components emit srcset + sizes; the contract above; the lint/test guard.

  • Slice 4 — validate + gate. validate-data invariant (D7); Layer-1 mobile e2e asserts the mobile viewport fetches the small candidate; verify per-client fetch at mobile/desktop/4K-TV viewports.

Migration

Slice 1 reuses the Slice-0 pattern: bump the crop-variants cache key (already includes cap + quality) so the pipeline re-derives, plus an in-place optimize pass for the committed masters. Idempotent — a master already ≤ cap and reasonably sized is skipped.

File-count note: the ladder adds ~3 derived files per master (~9 k new files). We are VPS-only now (GH Pages retired), so file-count is far less critical than under GH Pages' soft limit — but the ladder is still kept short (3 rungs) to bound it.

Risks

  • sizes correctness — per-component; wrong values silently over/under-fetch. Mitigate with the Slice-3 lint/test guard.
  • q85 on heroes at TV scale — visual sign-off on a 3840 px viewport before Slice 1 commits.
  • Hotspot exclusion must be airtight — the cap/ladder must never touch hotspots/ (an accidental downsize breaks zoom). Enforced by path exclusion + a test.
  • Derived-orphan drift — deleted masters leaving stale ladder files; the D7 orphan check catches it.
  • WebP-only, no <picture>/jpg fallback (shipped decision) — all three app targets are WebP-native (Capacitor iOS 15 WKWebView, Android + Google-TV Chromium WebView) and web is ~96% (caniuse); the ~4% ancient-web-browser gap is accepted (ADR-079). The base src is itself a .webp, so browsers ignoring srcset still get WebP.

Acceptance

  • build/images ≤ ~750 MB; deploy ≤ ~1.1 GB.
  • At mobile / desktop / 4K-TV viewports, the network panel shows each fetching its appropriate ladder rung.
  • Heroes are sharp on a 50″ 4K viewport; q85 passes visual sign-off.
  • hotspots/ bytes unchanged.
  • Dropping an oversized image and running validate-data fails until re-derived (the churn guarantee).
  • Layer-1 mobile e2e asserts the mobile viewport fetches the small candidate.

Decisions locked 2026-07-08 (were open questions)

  • Ladder rungs = 1280 / 2048 / 3072 (3 rungs; no 1536).
  • WebP is folded into this work, not deferred — the ladder is derived as WebP (Slice 2 encodes WebP directly; ADR-079 D3). No separate Slice 5.
  • Google TV = Capacitor Android (Android-TV target). So TV is a Capacitor build like mobile → it streams images from the CDN (shares the mobile stream-heavy path); srcset still picks the large rung by the TV's 4K viewport.
  • git-LFS backing store = GitHub LFS. Cost analysis below.

git-LFS cost estimate (GitHub, metered billing 2026)

Rates (current — data packs removed, now metered): 10 GiB storage + 10 GiB bandwidth free/month (Free/Pro; 250 GiB Team/Enterprise); overage $0.07/GiB-mo storage, $0.0875/GiB bandwidth.

Our profile: masters ≈ 1.05 GB / 3 067 files, high churn (~154 image commits / 60 days).

  • Storage: masters ~1 GB, growing ~0.5–1 GB/yr from churn + new missions/fleet — well under the 10 GiB free tier for years → $0.
  • Bandwidth (the variable): LFS bytes are billed on download. Masters are needed only by the machine that encodes derivatives (local). CI + deploy use the committed derived tree (regular git) and must skip the LFS smudge for masters (lfs.fetchexclude/GIT_LFS_SKIP_SMUDGE=1) — they never pull masters. With that, bandwidth is just occasional dev/agent clones (~1–5 GB/mo) → under free → $0.
  • Anticipated monthly cost ≈ $0, conditional on CI/agents not smudging masters. The failure mode is uncontrolled: if every CI run / fresh agent env pulls the ~1 GB of masters, bandwidth blows past 10 GiB fast (e.g. 40 GB over = ~$3.50/mo). Mitigation is mandatory config, not optional.

Open questions

  1. RESOLVED (2026-07-08) — bulk re-encode vs the image-authorization system. validate-image-dupes + validate-image-phash-dupes are hand-curated authorization gates (byte ALLOWLIST keyed on 8-char SHA-256 + a phash baseline) that scanned the derived static/images tree, so a bulk re-encode (Slice 1b cap; Slice 2 WebP) invalidated the whole allowlist (Slice 1b produced 140 un-allowlisted byte groups + 68 phash pairs). Fix (option a): the validators + compute-phash now key on masters/ — the stable source of truth, byte-identical to the pre-cap originals, so the existing allowlist matches unchanged and re-encoding the derived tree no longer touches dedup. Masters are LFS fetchexcluded, so each validator skips when masters are pointer stubs (CI / fresh clones) and runs only where smudged (git lfs pull -I 'masters/**') — dedup is now a local authoring check, not a CI gate. Verified: after the −311 MB cap, validate-data stays green. Content triage of the dupes themselves stays in #384.
  2. Does validate-data's invariant check dimensions cheaply enough at scale (~9 k derived files), or does it need a cached manifest of derived sizes?
  3. Do derived images also go to LFS, or only masters? Default (chosen for now) is masters-only — derived committed to regular git, served from the checkout, and CI/deploy skip master smudge (keeps LFS bandwidth ≈ $0). Moving derived to LFS too would keep main-git history lean against churn, but the VPS would LFS-pull derived on every deploy — bandwidth = derived-size × deploys/mo (~750 MB × N), which does incur cost and pulls masters-skip logic apart. Revisit only if main-git history growth becomes the bigger pain.

Orrery · RFC-030 · v0.8 — responsive image delivery. Extends ADR-079; locked decision in ADR-080.

Orrery — architecture documentation · MIT · No tracking