34 — Image Delivery & Performance (envisioning)
Active — backend half BUILT 2026-06-14 (
tscclean; owner green-lit). Governed by00-ROADMAP.md. Created 2026-06-14 (owner-raised). Owner: covers load slowly on the Home grid; wants a low-cost alternative to a CDN — "the size needed for display… best-practice and optimal for our project." Finding: the fix was mostly already built and unused. Decisions (end of doc) resolved; iOS half + backfill run remain.
Status (2026-06-19): ✅ backend complete — every cover upload generates + persists right-sized variants (
File.thumbnailUrl/thumbnail2xUrl/mediumUrl,ArtistSpace/Project.imageThumbnail/ imageThumbnail2x/imageMedium), auto-signed by the global interceptor. The 300²thumbnail2xtier was added 2026-06-19 (Claude Code;nest buildclean,jest14/14) to fix high-density blur — see §Device-aware selection. Backfill script written + extended for the 2x tier (scripts/backfill-image-variants.ts, dry-run-gated). ⏳ remaining (iOS only): the client variant-selection helper (thumb ≤150 · thumb2x ≤300 · medium ≤600 · original, by rendered px) + durable disk cache, and a real-data dry-run of the backfill (or re-upload the small beta set). Ships to TestFlight on the next container redeploy.
What triggered this
On the Artists/Roster home the cover images take a long time to appear (and the halo, which is derived from them — doc 22 §6 — therefore lands late and from the wrong image). The owner correctly named the trade-off: a CDN is the heavyweight answer but high-cost for now; they want the optimal, cheaper path.
Finding — the slowness is self-inflicted, and the cure already exists (verified 2026-06-14)
The backend already has full image-variant machinery — it's just not wired into the cover paths:
- Variants are implemented —
image.service.tsproduces three WebP variants per image: thumbnail 150×150 (cover-cropped), medium 600px (aspect-kept), original (optimized WebP).azure/blob-storage.service.ts → uploadImageWithVariantsuploads them askey-thumb/key-medium/ original and returnsthumbnailUrl+mediumUrl+ originalurl. - …but the cover uploads don't use it. Track / project / artist image uploads call the plain
storageService.uploadFile(seetrack.service.tsimage branch), which stores the original only — no variants generated. - …and the
Fileschema only persistsblobUrl. Even whereuploadImageWithVariantsis called, the returnedthumbnailUrl/mediumUrlare dropped — there's nowhere to store them. - Result: every surface — a 150 px home tile, a list row, a hero — downloads the full-resolution original. That's the lag. The halo (doc 22 §6) inherits it, resolving late.
So this is not a "we need a CDN" problem. It's "we generate the right sizes but throw them away and ship the biggest one everywhere." Fixing the wiring is ~free and is the standard best-practice (right-size at the source + cache at the client).
The approach — right-size at the source, cache at the client (no CDN required)
- Generate variants on every cover upload — route the track / project / artist image uploads through the existing
uploadImageWithVariantsinstead ofuploadFile. (Code already exists; it's a call swap + plumbing.) - Persist + sign the variant URLs — add
thumbnailUrl?/mediumUrl?to theFileschema (additive, back-compat).signUrlsInObjectalready signs storage URLs recursively in responses, so the new fields sign for free. Why persist (not derive): Azure SAS tokens are per-blob-name — a client cannot string-swap…-thumbonto an already-signed original URL (the signature would break). Variants must be stored and signed server-side. - Client requests the size for the context — thumb (150) for home tiles / list rows / avatars, medium (600) for heroes, original only on explicit full-view. This alone cuts payloads ~10–50× and is the single biggest win.
- Client-side cache — TWO tiers (✅ both shipped 2026-06-21).
AsyncImagenot only lacked a durable disk cache, it always cycles.empty → .success(a placeholder frame even on a cache hit, becauseURLCacheholds raw bytes that still need an async decode — that frame is the "default cover" flash).- Tier 1 —
URLCache(bytes/disk, 150 MB) — configured 2026-06-14 (ImageCache.configure()). - Tier 2 —
DecodedImageCache(NSCache<NSURL, UIImage>, ~96 MB of decoded images) + theCachedImageview (Core/CachedImage.swift), which seeds from this cache ininitso a previously-seen cover paints on the first frame — no placeholder, no flash. Misses decode off-main and crossfade in. ReplacesAsyncImageinArtworkHero/ProjectCard/TrackRow(avatars = a follow-up sweep). This is the real fix for the flashy/laggy covers — the decoded tier, not just bytes.
- Tier 1 —
- Prefetch (optional) — warm the next rows' thumbs just off-screen for buttery scroll.
CDN — explicitly DE-PRIORITIZED (owner call 2026-06-21). A CDN (Front Door — 05-CDN-FRONTDOOR.md) is expensive and NOT a must for now; it does not fix payload size or client caching, which were the actual bottleneck and are now solved for free by the right-sized variants + the two-tier client cache (above). CDN moves to a far-future scale lever (edge latency at real volume) — out of the near-term envision, including for the free tier.
Backend vs iOS map
| Piece | Today | Work | Owner |
|---|---|---|---|
| WebP variants (thumb/medium/original) | ✅ built (image.service.ts) | none | — |
| Cover uploads generate variants | ❌ uses uploadFile | swap to uploadImageWithVariants in track/project/artist image paths | backend (Claude Code) |
| Persist variant URLs | ❌ only blobUrl | thumbnailUrl?/mediumUrl? on File + serialize (auto-signed) | backend |
| Backfill existing covers | ❌ originals only | batch job to generate variants for current images (or lazy-on-access) | backend (decision) |
| Right-size per context | ❌ original everywhere | tiles→thumb, heroes→medium; pick in the image components | iOS (Xcode) |
| Durable image cache | ❌ AsyncImage no disk cache | disk-backed cache + (optional) prefetch | iOS |
How this sequences
High user-visible value and mostly reuse — a strong near-term slice, not a far-future one. Backend first (variant wiring + persisted URLs + backfill), then iOS (size-selection + cache), per the backend-contract-first rule. The halo work (doc 22 §6) directly benefits: a cached, right-sized cover means the vibrant color resolves immediately, killing the late/late-from-wrong-image halo too.
Decisions — to confirm (not now; flagged)
- Where to store variant URLs — explicit
thumbnailUrl?/mediumUrl?props (recommended — simplest, mirrors the storage return, signs cleanly) vs avariants {}sub-object vs the existingmetadatafield. - Backfill existing covers — one-off batch job over current images (recommended, finite set) vs lazy generation on first access vs leave old originals as-is (only new uploads get variants).
- S3 parity —
uploadImageWithVariantsis Azure-only today (STORAGE_PROVIDER=s3falls back to plain upload). Add S3 variant support now, or defer until/if S3 is used? (Defer — Azure is the live provider.) - Thumbnail shape — 150×150 cover-crop is good for tiles/avatars; heroes use medium (aspect-kept). Confirm no separate hero-crop is needed.
Until built, covers stay originals-only; nothing here blocks the doc-26 waves.
Device-aware variant selection + high-density sizing (owner-raised 2026-06-19)
Owner: Artist images in the Roster grid (and other tiles) look low-res on high-density / large devices — iPhone 17 Pro Max (@3x), iPads. Root cause: the 150×150 thumb is smaller than the pixels actually drawn. A 60 pt avatar at @3x already needs 180 px; larger tiles and iPad layouts need more. Shipping the 150² thumb to every non-hero surface therefore upscales → blur.
The fix is variant selection, not a CDN (consistent with this doc's thesis — right-size at the source):
- Pick the variant by rendered pixels, not by role. Compute
targetPx = renderedPointSize × UIScreen.main.scaleat the call site (the image component knows its frame). Usethumbonly whentargetPx ≤ 150,medium(600) when150 < targetPx ≤ 600, original above. So a Roster avatar on a Pro Max naturally pullsmedium, not the 150² thumb. This alone fixes the blur with zero backend work — both variants already exist and are signed. - ✅ Mid thumb (300²) added server-side 2026-06-19 (Claude Code) so small-but-not-tiny tiles get a crisp small payload instead of jumping straight to 600. Shipped as explicit additive props (per Decision #1, not a
variants {}map):File.thumbnail2xUrl?+ArtistSpace/Project.imageThumbnail2x?, generated byimage.service.ts(thumbnail2x, 300² cover-crop), uploaded as<key>-thumb2x, persisted across all cover paths, backfill extended. The client just needs the selection logic in item 1 to consume it. - Don't hard-code device checks. Drive off
UIScreen.scale+ the actual frame, so new devices (higher scale, bigger iPads) adapt automatically — no per-model branching to maintain.
Net: client-side selection logic is the 90% fix and the only remaining piece — with the 300² tier now live, the ladder is thumb 150 → thumb2x 300 → medium 600 → original. Selection becomes a small reusable helper (
imageURL(forTargetPoints:)on the model):targetPx = points × UIScreen.scale, pick the smallest variant whose size ≥targetPx(thumb ≤150 · thumb2x ≤300 · medium ≤600 · original above).
Cross-references
- Existing variant code:
ctrl-audio-back/src/modules/image/image.service.ts+azure/blob-storage.service.ts(uploadImageWithVariants) - Halo reads from these images:
22-IOS-DESIGN-SYSTEM.md§6 - CDN (later scale lever, not the fix here):
05-CDN-FRONTDOOR.md - Mobile delivery patterns:
13-MOBILE-CONSIDERATIONS.md· scale:14-SCALABILITY.md