Generate thumbnails and display derivatives at ingest #93
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH - blocks the browser being usable at all
The problem
The only media the backend can serve is the original scan. There is no thumbnail endpoint, no
derivative generation, and no resize code anywhere (grep confirms no Pillow resize call).
Consequences today:
(
frontend/src/pages/PhotoBrowserPage.tsx:29), and.photo-tile-imginstyles.css:398isdead CSS. Browsing a photo collection currently means reading filenames.
image/tiffis accepted at ingest, and browsers cannot render TIFF in<img>at all — soderivatives are a display-correctness requirement, not merely a performance optimization.
The moment real images are added to the grid without derivatives, 100 tiles × multi-megabyte
originals is catastrophic.
Scope
JPEG or WebP (around 2048px) and a thumbnail (around 400px).
/media/thumband serve the review-size derivative in the workspace, with the original oneclick away.
tile.
width/heightanddecoding="async"on the img elements to avoid layout thrash.Done when
References
frontend/src/pages/PhotoBrowserPage.tsx:29;styles.css:398backend/app/api/routes/photos.py:168-191backend/app/services/ingest.pyRelated: #48 scheduled this at v0.5.0, which is far too late. Pair with the media caching issue.
Done in
ae6dc43. Migration011,app/services/derivatives.py, aderivativesjob type, a backfill CLI, and 24 backend + 5 component tests.Most of the machinery already existed
#3 built a sandboxed
_downscalefor shrinking images before an AI upload. So this is mostly reuse — with one addition worth naming: aderivativesoperation that produces both sizes from a single decode.Measured: two separate calls cost ~480 ms, one call ~300 ms. The decode dominates and each sandbox launch is ~125 ms on top, so asking twice pays for both twice. Each size is resampled from the full-resolution source rather than by shrinking the review image — chaining compounds resampling error, and the thumbnail is the image a reviewer scans a grid of and can least afford softness in.
Verified on a real 25.9 MB TIFF: 463 ms → a 924-byte thumbnail and a 16 KB review JPEG.
Inline at ingest, not a job
The issue asks for both ("generate at ingest", "backfill … a job type fits naturally"), and the split matters:
Ingest builds them inline. A job would keep uploads fast, but the worker is a separate process a deployment may not have started — and a photograph with no thumbnail is a camera emoji in the grid, which is the exact bug being fixed. ~300 ms on a request that is already threadpooled (#86) and already launches two subprocesses for verification and EXIF.
The
derivativesjob handles backfill and retry, where the work is genuinely deferred.python -m app.cli.backfill_derivativesenqueues rather than working: 300 ms a scan across a collection of thousands is a job queue's problem, and a long-running CLI whose failure loses its place is not the answer. It is idempotent — a photo with a job already waiting is skipped — and--retry-failedis opt-in, because sweeping up scans known not to decode would fill the queue with work that cannot succeed.Serving
/media/thumb— the grid. Novariantparameter: a grid that could ask for originals is the catastrophe the endpoint prevents./media/front?variant=review— the workspace./media/front— still the untouched original.That last default is deliberate and is the decision I'd most want a second opinion on. For an archive the safe answer to a bare request for a photograph's front is the archival bytes; the optimisation is opt-in. A caller who wants the real thing and forgets a parameter must not silently receive a re-encoded JPEG. The workspace keeps a View original link so the archive is always one click away.
Everything falls back to the original when a derivative is absent — which is what lets the backfill be a background job rather than a migration everyone waits for.
Two properties the tests hold
The original is never touched. A derivative is an additional object at a key derived from the original's content-addressed key, so the two cannot drift and a second run writes nothing.
A scan that will not decode is recorded, not raised. The bytes are still worth keeping and the photograph is still worth a row.
derivatives_statusandderivatives_errorcarry the fact,has_thumbnailreaches the client, and the tile shows a warning rather than a broken image that would look like a load in progress (#78).Frontend
Real
<img>tiles with intrinsicwidth/heightanddecoding="async". Without the dimensions a hundred-tile grid reflows as each image lands, moving the tile out from under the reviewer's cursor between deciding to click and clicking — a new failure mode that a grid of emoji did not have.Done when
1135 backend, 64 component, 7 e2e — the reviewer journey now asserts the grid tile has
naturalWidth, so "the tile is a photograph" is checked in a real browser rather than inferred from the markup.One knock-on:
#8's route matrix failed the moment/media/thumbappeared, exactly as designed — a new endpoint cannot ship without someone declaring who may reach it.