Repository navigation
feat(images): add LQIP placeholders (dominant_color + blur_data_url) - #34
Merged
Merged
Conversation
Every ready image record now carries two tiny, self-contained placeholder values so a consumer can paint something meaningful before the real bytes arrive: `dominant_color` (average colour as `#rrggbb`) and `blur_data_url` (a 16px WebP as a `data:image/webp;base64,...` URI, ~135-170 bytes in practice). Migration `0008` adds both columns, nullable, no backfill. Both are exposed regardless of `visibility`, deliberately breaking the "extras are public-only" symmetry of `storage_key`/`renditions`/accelerator URLs: they carry no storage key, no URL and nothing cross-tenant, and a private record needs a placeholder exactly as much as a public one. `IMAGE_PIPELINE_VERSION` is deliberately NOT bumped. This adds metadata; it does not change the bytes of any stored object, so a bump would force a pointless re-encode of every re-uploaded image. Pre-existing rows carry NULL. The placeholder tile is derived from the **encoded output buffer**, after the main `write_to_buffer`, and that placement is load-bearing twice over: - pyvips recomputes a pipeline for every independent sink, so extracting from the full-resolution source forced a *second* full decode + ICC transform. Measured on a real 24.5MP iPhone photo: 591ms from the source vs 15.8ms reading the output back. `thumbnail_buffer` on the source is not the fix either -- shrink-on-load does not exist for PNG, where it measured 62.7ms, slower than on HEIC. - It must sit *below* the `MAX_IMAGE_PIXELS` guard. Extraction is the first call that forces evaluation, so above the guard a decompression bomb is fully decoded before being rejected: a 120KB, 36-megapixel PNG took 256ms to reject instead of 0.3ms, on the synchronous request path. A new test pins that ordering by monkeypatching the extractor to raise, so it fails deterministically rather than on a timing threshold. The performance test now measures real encoded bytes -- an earlier version called `.copy_memory()` first, which pre-materialized the pixels and reported 1.6ms for what is a 591ms operation on a real photo. Verified: 487 passed / 14 skipped; ruff check, ruff format --check and mypy clean. Migration applies, `downgrade -1` rolls back, and re-applies. Measured end to end through the running stack on real iPhone photos -- a 4284x5712 HEIC is 763ms total (vs a 745ms pre-placeholder baseline, so ~18ms of placeholder cost), 3024x4032 is 502ms, and a 1024x1024 JPEG is 110ms; blur payloads are 135-167 bytes against a 1200-byte tripwire.
|
This was referenced Aug 24, 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.



Every ready image record now carries two tiny, self-contained placeholder values so a consumer can paint something meaningful before the real bytes arrive:
dominant_color— average colour as#rrggbb, for a solid background behind a loading image.blur_data_url— a 16px WebP as adata:image/webp;base64,...URI (135–167 bytes in practice), for a blurred preview (Next.jsplaceholder="blur"and equivalents).Migration
0008adds both columns, nullable, no backfill.alembic downgrade -1rolls back and re-applies cleanly — verified against the real Postgres service.Exposed regardless of visibility
This deliberately breaks the "extras are public-only" symmetry of
storage_key/renditions/ accelerator URLs. These two fields carry no storage key, no URL and nothing cross-tenant, and a private record needs a placeholder exactly as much as a public one. A test asserts placeholders are present on a private upload whilestorage_key,renditionsandthumbnail_urlare absent.IMAGE_PIPELINE_VERSIONis deliberately NOT bumpedThis adds metadata; it does not change the bytes of any stored object. Bumping would force a pointless re-encode of every re-uploaded image. Pre-existing rows carry
NULL. Flagging it here because a reviewer would reasonably expect the opposite.Where the tile comes from, and why it matters
The placeholder is derived from the encoded output buffer, after the main
write_to_buffer. That placement is load-bearing twice over — both found by measuring on a real 24.5MP iPhone photo rather than a fixture:thumbnail_bufferon the source is not the fix either — shrink-on-load does not exist for PNG, where it measured 62.7ms, slower than on HEIC.MAX_IMAGE_PIXELSguard. Extraction is the first call that forces evaluation, so above the guard a decompression bomb decodes fully before being rejected: a 120KB, 36-megapixel PNG took 256ms to reject instead of 0.3ms, synchronously, on an endpoint rate-limited at only 2 r/s per IP.test_oversized_image_rejected_before_any_placeholder_workpins that ordering by monkeypatching the extractor to raise, so it fails deterministically rather than on a timing threshold.The performance test was also rewritten: the original called
.copy_memory()first, which pre-materialized the pixels and hid the decode — it reported 1.6ms for what is a 591ms operation on a real photo.The trade-off, measured
Deriving from the lossy, downscaled output rather than the original source could in principle shift the placeholder. Measured across three real photos and all three optimization profiles:
At most one unit out of 255. Blur payload sizes are identical across profiles.
Verification
487 passed, 14 skipped·ruff check·ruff format --check·mypy app— all clean viadocker compose run --rm --build test.End to end through the running stack on real iPhone photos:
Blur payloads 135–167 bytes against a 1200-byte tripwire test.
Notes for the reviewer
INSERTinPostgresMetadataStore.createwent from 15 to 17 placeholders — column list,$Nlist and positional args all verified to line up._image_responsewas 11 parameters after refactoringcustom_*into the sharedCustomImageParamsdataclass, down from 12 and under the ≤13 bound.docs/plans/media-engine/session-2-placeholders.mdcarries a "Corrected after implementation" note: the plan's original guidance (extract from the normalized source, before the max-dim resize) was wrong on both counts, and the note records the measurements so the mistake isn't repeated.