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
srcsetor 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:
- 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. - 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:
| Population | Size | Files | Status |
|---|---|---|---|
| 1×1 thumbnails | ~76 MB (was 653) | 2,523 | Done — Slice 0 (crop-variants 512 px cap + in-place migration this cycle) |
| Full-size (hero / gallery / lightbox) | 1,049 MB | 3,067 | this RFC |
hotspots/ (3D surface tier2/tier3 pan/zoom) | 176 MB | 232 | excluded — 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):
Cap Quality Saves Files actually resized 2048 px q82 435 MB 301 2560 px q85 348 MB 158 3072 px q85 326 MB 69 3840 px q85 312 MB 40 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:
<!-- 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/posterspage 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/stagingpromote +fetch-assets): cap the master (3840/q85) and derive the ladder + thumbnail automatically — no oversized image enters the shipped tree. validate-datainvariant (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 = onenpm 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
| Rung | Width | Consumer | Rationale |
|---|---|---|---|
| thumbnail | 512 px | 1×1 cards / mobile rows | ≤140 px display × DPR3 (done, Slice 0) |
| display-s | 1280 px | mobile lightbox/hero (streamed) | ~400 CSS px × DPR3; light over cellular |
| display-m | 2048 px | desktop / laptop | ≤1440p retina lightbox |
| display-l | 3072 px | 4K TV / 4K monitor | 90vw of 3840 ≈ 3456 → 3072 nearest |
| original (master) | full res | not 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:
| Component | sizes |
|---|---|
PanelHeroImage (detail hero) | 100vw |
PanelLightbox (full view) | 90vw |
| gallery card / fleet row | 140px (→ thumbnail, already 1×1) |
| mission/fleet grid tile | minmax(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-variantscapped 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:
git lfs install;git lfs track "masters/**"(→.gitattributes).- Commit
.lfsconfigwithfetchexclude = 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 (allactions/checkoutdefaultlfs: false; the static deploy rsyncsbuild/, never git-LFS);.lfsconfigmakes that structural + covers the VPS-sidegit fetch+resetcheckout. - Copy the current full-res
static/imagesoriginals intomasters/(LFS-tracked), preserving structure — the permanent originals, before any derivative is capped. - 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-variantsto emit the 1280/2048/3072 rungs encoded WebP (ADR-079 D3 folded in here, not deferred) next to each master; wire intoimages:score+ promote (D7). Stacks ~30 % on top of the cap.Slice 3 — wire
srcset.PanelHeroImage,PanelLightbox, gallery/grid components emitsrcset+sizes; the contract above; the lint/test guard.Slice 4 — validate + gate.
validate-datainvariant (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
sizescorrectness — 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 basesrcis itself a.webp, so browsers ignoringsrcsetstill 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-datafails 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);
srcsetstill 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
- RESOLVED (2026-07-08) — bulk re-encode vs the image-authorization system.
validate-image-dupes+validate-image-phash-dupesare hand-curated authorization gates (byteALLOWLISTkeyed on 8-char SHA-256 + a phash baseline) that scanned the derivedstatic/imagestree, 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-phashnow key onmasters/— 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 LFSfetchexcluded, 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-datastays green. Content triage of the dupes themselves stays in #384. - 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? - 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.