Skip to content

34 — Image Delivery & Performance (envisioning)

Active — backend half BUILT 2026-06-14 (tsc clean; owner green-lit). Governed by 00-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² thumbnail2x tier was added 2026-06-19 (Claude Code; nest build clean, jest 14/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 implementedimage.service.ts produces three WebP variants per image: thumbnail 150×150 (cover-cropped), medium 600px (aspect-kept), original (optimized WebP). azure/blob-storage.service.ts → uploadImageWithVariants uploads them as key-thumb / key-medium / original and returns thumbnailUrl + mediumUrl + original url.
  • …but the cover uploads don't use it. Track / project / artist image uploads call the plain storageService.uploadFile (see track.service.ts image branch), which stores the original only — no variants generated.
  • …and the File schema only persists blobUrl. Even where uploadImageWithVariants is called, the returned thumbnailUrl / mediumUrl are 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)

  1. Generate variants on every cover upload — route the track / project / artist image uploads through the existing uploadImageWithVariants instead of uploadFile. (Code already exists; it's a call swap + plumbing.)
  2. Persist + sign the variant URLs — add thumbnailUrl? / mediumUrl? to the File schema (additive, back-compat). signUrlsInObject already 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 …-thumb onto an already-signed original URL (the signature would break). Variants must be stored and signed server-side.
  3. Client requests the size for the contextthumb (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.
  4. Client-side cache — TWO tiers (✅ both shipped 2026-06-21). AsyncImage not only lacked a durable disk cache, it always cycles .empty → .success (a placeholder frame even on a cache hit, because URLCache holds 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) + the CachedImage view (Core/CachedImage.swift), which seeds from this cache in init so a previously-seen cover paints on the first frameno placeholder, no flash. Misses decode off-main and crossfade in. Replaces AsyncImage in ArtworkHero / ProjectCard / TrackRow (avatars = a follow-up sweep). This is the real fix for the flashy/laggy covers — the decoded tier, not just bytes.
  5. 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

PieceTodayWorkOwner
WebP variants (thumb/medium/original)✅ built (image.service.ts)none
Cover uploads generate variants❌ uses uploadFileswap to uploadImageWithVariants in track/project/artist image pathsbackend (Claude Code)
Persist variant URLs❌ only blobUrlthumbnailUrl?/mediumUrl? on File + serialize (auto-signed)backend
Backfill existing covers❌ originals onlybatch job to generate variants for current images (or lazy-on-access)backend (decision)
Right-size per context❌ original everywheretiles→thumb, heroes→medium; pick in the image componentsiOS (Xcode)
Durable image cacheAsyncImage no disk cachedisk-backed cache + (optional) prefetchiOS

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)

  1. Where to store variant URLs — explicit thumbnailUrl?/mediumUrl? props (recommended — simplest, mirrors the storage return, signs cleanly) vs a variants {} sub-object vs the existing metadata field.
  2. 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).
  3. S3 parityuploadImageWithVariants is Azure-only today (STORAGE_PROVIDER=s3 falls back to plain upload). Add S3 variant support now, or defer until/if S3 is used? (Defer — Azure is the live provider.)
  4. 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):

  1. Pick the variant by rendered pixels, not by role. Compute targetPx = renderedPointSize × UIScreen.main.scale at the call site (the image component knows its frame). Use thumb only when targetPx ≤ 150, medium (600) when 150 < targetPx ≤ 600, original above. So a Roster avatar on a Pro Max naturally pulls medium, not the 150² thumb. This alone fixes the blur with zero backend work — both variants already exist and are signed.
  2. ✅ 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 by image.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.
  3. 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

Ctrl-Audio Platform Documentation