API: serve cropped person/project thumbnails (#1432) - #1433
Merged
Conversation
`thumbnail` on people and projects returned the raw upload, ignoring the crop box editors set in the admin: 33.5 MB for the 13 headshots Project Sidewalk's About page renders, framed wherever the source photo happened to be centered. Consumers couldn't self-serve either, since derivatives are generated server-side and an unrendered size 404s. `thumbnail` is now the cropped derivative -- 256x256 for a person, 1000x600 for a project, each keeping its model's crop aspect ratio so nothing is cropped a second time (#1424). The raw file moves to a new `image_original` field. This is the one exception taken to v1's additive-only rule, recorded in docs/API.md: a field named `thumbnail` handing out an 11 MB original was a bug, not a contract. - website/utils/thumbnail_utils.py: shared crop-aware generation, promoted from _build_thumbnail_url in view_project_people.py (which now delegates to it). - warm_api_thumbnails: idempotent one-shot, wired into docker-entrypoint.sh, so the first request after a deploy doesn't generate every derivative inline. - Tests pin the derivative URL/size, the nested person on project people, the missing-source fallback, and the size/crop aspect-ratio match. The people N+1 guard now warms before measuring (generation writes cache rows). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… test assertions - warm_api_thumbnails covers every person with a photo, not just those with a Position: PersonSummarySerializer also nests in publication `authors`, so external co-authors were left to generate inline on the first request. - Filter out image-less rows properly. gallery_image is null=True and Django's .exclude(field="") keeps NULLs, so projects with no image were reaching the helper and being reported as failures on every container start. The run now reports generated / already cached / failed separately. - get_cropped_thumbnail takes generate=False, so a caller can ask "is this cached?" without paying to make it. The command's tests use it: they were asserting existence via the generating path, which passed whether or not the command did anything. The dry-run test is likewise scoped to its own person rather than scanning the shared MEDIA_ROOT (order-dependent). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…umbnails # Conflicts: # makeabilitylab/settings.py
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.
Fixes #1432.
Problem
thumbnailon/api/v1/people/, the nestedpersonon/api/v1/projects/<short_name>/people/, and/api/v1/projects/all returned theraw uploaded file, ignoring the crop box editors set in the admin. Project
Sidewalk's About page pulls 13 headshots from this API: 33.5 MB, framed
wherever each source photo happened to be centered rather than by the crop box.
Consumers couldn't work around it — derivatives are generated server-side, so a
size the site has never rendered 404s.
What changed
thumbnailimage_originalMeasured locally (
jonfroehlich): 1,118,406 B → 17,815 B. Project galleryimages: e.g. makerwear 8.85 MB → 124 KB, handsight 6.27 MB → 87 KB.
Each API size keeps the aspect ratio of its model's
ImageRatioField(Person245×245, Project 500×300). That's load-bearing: a mismatch makes easy-thumbnails
center-crop a second time on top of the editor's box, which is what clipped
heads off the news cards in #1424. A test pins the ratios.
Implementation
website/utils/thumbnail_utils.py(new) — one crop-aware generator, promotedfrom
_build_thumbnail_urlinview_project_people.py(which now delegates toit). It passes the same easy-thumbnails options the templates do, so the API
shares their cached files, and swallows/logs failures — from Python there is no
equivalent of the template tag's silent net.
warm_api_thumbnails(new management command, wired intodocker-entrypoint.shstep 4.10e) — the API's sizes aren't rendered anywhere onthe site, so without this the first request after a deploy generates every
derivative inline, decoding a page's worth of multi-MB photos against the 120 s
Gunicorn timeout. Idempotent: after the first run it's a couple of stat calls
per row.
docs/API.md— new Images section, plus a note under Stability contract.Stability contract
This takes the one and only exception to "v1 fields are additive-only", recorded
in the docs: a field named
thumbnailhanding out an 11 MB uncropped original wasa bug, not a contract, and the raw file is still one field away. Both known
consumers are ours (Project Sidewalk's About page, the recent-pubs widget).
Testing
python manage.py test website --settings=makeabilitylab.settings_test→ 685tests, OK (8 pre-existing skips).
256×256 / 1000×600 on disk; nested
personon project people carries bothfields; no image → both
null; missing source file falls back to the originalinstead of 500ing;
warm_api_thumbnailswarms, re-runs idempotently, andsurvives a broken row.
easy-thumbnails cache rows; the cached path is filesystem stats only.
(payloads above, plus
/people/,/projects/,/view-project-people/→ 200).No UI change, so no screenshots / Pa11y run.
🤖 Generated with Claude Code