Skip to content

API: serve cropped person/project thumbnails (#1432) - #1433

Merged
jonfroehlich merged 3 commits into
masterfrom
1432-api-cropped-thumbnails
Jul 28, 2026
Merged

API: serve cropped person/project thumbnails (#1432)#1433
jonfroehlich merged 3 commits into
masterfrom
1432-api-cropped-thumbnails

Conversation

@jonfroehlich

Copy link
Copy Markdown
Member

Fixes #1432.

Problem

thumbnail on /api/v1/people/, the nested person on
/api/v1/projects/<short_name>/people/, and /api/v1/projects/ all returned the
raw 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

Field Before After
thumbnail raw upload (up to ~11 MB) cropped derivative — 256×256 person, 1000×600 project
image_original the raw upload

Measured locally (jonfroehlich): 1,118,406 B → 17,815 B. Project gallery
images: 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 (Person
245×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, promoted
    from _build_thumbnail_url in view_project_people.py (which now delegates to
    it). 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 into
    docker-entrypoint.sh step 4.10e) — the API's sizes aren't rendered anywhere on
    the 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 thumbnail handing out an 11 MB uncropped original was
a 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_test685
    tests, OK
    (8 pre-existing skips).
  • New: derivative URL carries the crop box and requested size; the file really is
    256×256 / 1000×600 on disk; nested person on project people carries both
    fields; no image → both null; missing source file falls back to the original
    instead of 500ing; warm_api_thumbnails warms, re-runs idempotently, and
    survives a broken row.
  • The people N+1 guard now warms before measuring — thumbnail generation writes
    easy-thumbnails cache rows; the cached path is filesystem stats only.
  • Verified live against a container running this branch on the local dev DB
    (payloads above, plus /people/, /projects/, /view-project-people/ → 200).

No UI change, so no screenshots / Pa11y run.

🤖 Generated with Claude Code

jonfroehlich and others added 3 commits July 27, 2026 18:40
`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
@jonfroehlich
jonfroehlich merged commit a511357 into master Jul 28, 2026
3 checks passed
@jonfroehlich
jonfroehlich deleted the 1432-api-cropped-thumbnails branch July 28, 2026 04:24
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.

API: person thumbnail is the uncropped original — expose the cropped derivative

1 participant