Skip to content

Render resized WebP copies of library images through a sidecar - #239

Merged
ssavutu merged 1 commit into
mainfrom
feat/image-variants-sidecar
Oct 1, 2026
Merged

ssavutu merged 1 commit into
mainfrom
feat/image-variants-sidecar

Conversation

@ssavutu

@ssavutu ssavutu commented Oct 1, 2026

Copy link
Copy Markdown
Member

Why

The CMS stores uploads exactly as received, and since 2023 most of them are camera originals. The average original went from ~0.6 MB to 3+ MB, and some are 80 MB. WordPress used to make smaller copies on upload; the CMS doesn't, and the API hands out the original URL as featured_image. The live homepage loads ~33 MB of images into ~400px cards. Its largest is a 6000×4000, 4.9 MB JPEG; at 960px WebP the same image is 129 KB.

Change

Built the same way as the embeddings sidecar:

  • imaging/: a stateless FastAPI + libvips (pyvips-binary) service. POST /render?width=N takes the original's bytes and returns a WebP, with X-Image-Width/X-Image-Height. It:
    • applies EXIF rotation and converts to sRGB
    • strips EXIF/XMP/ICC, so the copies don't carry the GPS data the originals still do
    • never enlarges
    • returns 422 for undecodable images and for anything over 120 MP
  • internal/imaging reconciler (background loop):
    • renders the 480/960/1600/2400 ladder for every JPEG/PNG/WebP, newest first
    • skips GIFs so animations aren't replaced with a still frame
    • writes atomically to wp-content/variants/<recipe>/2026/08/foo.jpg.960w.webp. The recipe is in the path so a quality or size change produces new URLs, which matters behind Cloudflare's 30-day immutable cache.
    • takes a GET_LOCK so the blue and green slots don't both render
    • records each image in media_renditions (one row per media item, no FK), and deletes files for media that has been deleted
    • records unreadable images as failed instead of retrying them; an image that keeps crashing the sidecar gets three attempts
    • treats the first pass after deploy as the backfill: ~13k originals at ~1.5s each on 2 cores
  • API: featured_image_variants: [{url, width, height}] on article lists, details, related articles and search, and variants on media items. These come from an in-memory index reloaded every minute, so responses make no extra queries. featured_image is unchanged, and clients should keep it as the src fallback.
  • Deploy:
    • a shared imaging service in both compose files, capped at 2 CPUs and 1.5 GB
    • deploy.sh starts it non-fatally, through a wait_for_sidecar helper now shared with embeddings
    • publish.yml tags the image by the tree hash of imaging/
    • CI builds the image and validates compose with the derived tag

If the sidecar isn't there, nothing changes: the site serves originals as before.

Depends on

DrexelTriangle/triangle-infrastructure#9 creates /mnt/cephfs/media/wp-content/variants for uid 10001. It's already applied by hand, and Nginx serves it with 200.

Verified

  • go test -race ./..., go vet ./..., deploy_scripts_test.sh, and both compose configs pass. Swagger is regenerated.
  • The new media_renditions integration test passes against MariaDB 11.7.
  • End to end locally with the real sidecar container, MariaDB, and three homepage photos from Delta:
    • each produced its ladder (the 2048px PNG stops at 2048, with no upscale)
    • files are 0644
    • a garbage file was recorded as failed and not retried
    • the index resolved the article URL to the variant URLs
  • The tests caught a path bug before the PR: a variants/../uploads row could make cleanup delete an original. It's fixed and tested.

Follow-ups

  • Scalene: srcset + sizes on the card and lead <img> tags. That is where readers' downloads actually shrink.
  • CMS editor media grid: use variants for thumbnails.

🤖 Generated with Claude Code

https://claude.ai/code/session_019EcFyy8aqNnUd75CDPY6Bx

The CMS stores uploads exactly as received, and since 2023 most of them are
camera originals: 6000px wide, 5-80MB. WordPress used to generate smaller
copies on upload. The CMS never did, so the public site has been putting the
originals into 400px cards. The homepage was loading about 33MB of images.

This adds that step back, following the same pattern as the embeddings
sidecar:

- imaging/: a stateless FastAPI + libvips service. The backend sends it an
  original and a width, and it returns a WebP. It applies EXIF rotation,
  converts to sRGB, strips metadata (including GPS), never enlarges, and caps
  input at 120MP.
- internal/imaging: a background reconciler. It renders a 480/960/1600/2400
  ladder for each JPEG/PNG/WebP in the library, newest first. Files go to
  wp-content/variants/<recipe>/..., and the recipe in the path means a
  quality or size change produces new URLs under Cloudflare's immutable
  cache. Renditions are recorded in media_renditions and orphans are cleaned
  up. A GET_LOCK keeps the blue and green slots from both rendering. The
  first pass after deploy is the backfill.
- The API now returns featured_image_variants on article lists, details,
  related articles and search, and variants on media items. These come from
  an in-memory index reloaded every minute, so responses cost no extra
  queries. featured_image is unchanged and is still the fallback.
- Deploy: a shared imaging service in both compose files, started
  non-fatally by deploy.sh, and tagged by the tree hash of imaging/ in
  publish.yml. The image is also built in CI.

If the sidecar is absent, the site serves originals exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EcFyy8aqNnUd75CDPY6Bx
@ssavutu
ssavutu merged commit c525bc9 into main Oct 1, 2026
7 checks passed
@ssavutu
ssavutu deleted the feat/image-variants-sidecar branch October 1, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant