Render resized WebP copies of library images through a sidecar - #239
Merged
Merged
Conversation
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
This was referenced Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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=Ntakes the original's bytes and returns a WebP, withX-Image-Width/X-Image-Height. It:internal/imagingreconciler (background loop):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.GET_LOCKso the blue and green slots don't both rendermedia_renditions(one row per media item, no FK), and deletes files for media that has been deletedfeatured_image_variants: [{url, width, height}]on article lists, details, related articles and search, andvariantson media items. These come from an in-memory index reloaded every minute, so responses make no extra queries.featured_imageis unchanged, and clients should keep it as thesrcfallback.imagingservice in both compose files, capped at 2 CPUs and 1.5 GBdeploy.shstarts it non-fatally, through await_for_sidecarhelper now shared with embeddingspublish.ymltags the image by the tree hash ofimaging/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/variantsfor 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.media_renditionsintegration test passes against MariaDB 11.7.variants/../uploadsrow could make cleanup delete an original. It's fixed and tested.Follow-ups
srcset+sizeson the card and lead<img>tags. That is where readers' downloads actually shrink.variantsfor thumbnails.🤖 Generated with Claude Code
https://claude.ai/code/session_019EcFyy8aqNnUd75CDPY6Bx