Skip to content

Commit 3d0aef3

Browse files
feat: responsive image delivery with multi-size PNG + WebP (#5192)
## Summary - Replace single `plot_thumb.png` with responsive multi-size PNG + WebP variants (400/800/1200px) for all plot images - Frontend `<picture>` elements with `srcSet` + `sizes` let the browser pick optimal size based on viewport and DPR - Remove `preview_thumb` field from API, database sync, metadata templates, and all documentation - Graceful fallback to `plot.png` during migration (before backfill runs) ### Image savings (scatter-basic/matplotlib example) | Variant | Size | vs original | |---------|------|-------------| | plot.png (original) | 477K | baseline | | plot_800.webp (typical mobile) | 18K | **-96%** | | plot_400.webp (small mobile) | 7.2K | **-98%** | ### Changes (33 files) - **Backend**: `create_responsive_variants()` in `core/images.py` generates all 7 variants - **Frontend**: `<picture>` + `srcSet` in ImageCard, SpecDetailView, SpecOverview, CatalogPage - **Workflows**: `impl-generate.yml` and `impl-repair.yml` generate + upload all variants - **API**: Removed `preview_thumb`/`thumb` from schemas, routers, MCP server - **Scripts**: `backfill_responsive_images.py` for historical plots, `remove_preview_thumb_from_yaml.py` for YAML cleanup ### Post-merge steps 1. Run `python scripts/backfill_responsive_images.py` to generate variants for ~2,668 existing images (~2h) 2. Run `python scripts/remove_preview_thumb_from_yaml.py` → commit YAML cleanup separately 3. Later: Alembic migration to drop `preview_thumb` DB column Closes #5191 ## Test plan - [x] 1061 unit tests pass - [x] Frontend builds successfully - [x] Ruff lint + format clean - [x] Local test: responsive variants generated with correct sizes - [ ] Deploy and verify fallback to plot.png for historical images - [ ] Run backfill script on production GCS - [ ] Trigger impl-generate for one spec to verify new workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d4919b commit 3d0aef3

34 files changed

+597
-136
lines changed

.github/workflows/impl-generate.yml

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,6 @@ jobs:
391391
'python_version': py_ver,
392392
'library_version': lib_ver,
393393
'preview_url': f'https://storage.googleapis.com/pyplots-images/plots/{spec}/{lib}/plot.png',
394-
'preview_thumb': f'https://storage.googleapis.com/pyplots-images/plots/{spec}/{lib}/plot_thumb.png',
395394
'preview_html': preview_html if preview_html != 'null' else None,
396395
'quality_score': None,
397396
'review': {'strengths': [], 'weaknesses': []}
@@ -448,13 +447,15 @@ jobs:
448447
449448
source .venv/bin/activate
450449
451-
# Process PNG: optimize and create thumbnail
450+
# Optimize PNG and generate responsive variants (400/800/1200 x png/webp + full webp)
452451
python -m core.images process \
453452
"$IMPL_DIR/plot.png" \
453+
"$IMPL_DIR/plot.png"
454+
python -m core.images responsive \
454455
"$IMPL_DIR/plot.png" \
455-
"$IMPL_DIR/plot_thumb.png"
456+
"$IMPL_DIR/"
456457
457-
echo "::notice::Processed images: optimized + thumbnail created"
458+
echo "::notice::Processed images: optimized + responsive variants created"
458459
ls -la "$IMPL_DIR/"
459460
460461
# ========================================================================
@@ -532,25 +533,18 @@ jobs:
532533
echo "$GCS_CREDENTIALS" > /tmp/gcs-key.json
533534
gcloud auth activate-service-account --key-file=/tmp/gcs-key.json
534535
535-
# Upload PNG (with watermark)
536+
# Upload all plot images (original + responsive variants)
536537
if [ -f "$IMPL_DIR/plot.png" ]; then
537-
gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
538-
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
538+
gsutil -m -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR"/plot*.png "$IMPL_DIR"/plot*.webp "${STAGING_PATH}/"
539+
gsutil -m acl ch -u AllUsers:R "${STAGING_PATH}/plot*" 2>/dev/null || true
539540
echo "png_url=${PUBLIC_URL}/plot.png" >> $GITHUB_OUTPUT
540541
echo "uploaded=true" >> $GITHUB_OUTPUT
542+
echo "::notice::Uploaded plot.png + responsive variants (PNG + WebP)"
541543
else
542544
echo "::error::No plot.png found - cannot continue without image"
543545
exit 1
544546
fi
545547
546-
# Upload thumbnail
547-
if [ -f "$IMPL_DIR/plot_thumb.png" ]; then
548-
gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
549-
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
550-
echo "thumb_url=${PUBLIC_URL}/plot_thumb.png" >> $GITHUB_OUTPUT
551-
echo "::notice::Uploaded thumbnail"
552-
fi
553-
554548
# Upload HTML (interactive libraries)
555549
if [ -f "$IMPL_DIR/plot.html" ]; then
556550
gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"

.github/workflows/impl-repair.yml

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,15 @@ jobs:
159159
160160
source .venv/bin/activate
161161
162-
# Process PNG: optimize and create thumbnail
162+
# Optimize PNG and generate responsive variants
163163
python -m core.images process \
164164
"$IMPL_DIR/plot.png" \
165+
"$IMPL_DIR/plot.png"
166+
python -m core.images responsive \
165167
"$IMPL_DIR/plot.png" \
166-
"$IMPL_DIR/plot_thumb.png"
168+
"$IMPL_DIR/"
167169
168-
echo "::notice::Processed images: optimized + thumbnail created"
170+
echo "::notice::Processed images: optimized + responsive variants created"
169171
ls -la "$IMPL_DIR/"
170172
171173
- name: Upload repaired plot to GCS staging
@@ -186,14 +188,10 @@ jobs:
186188
echo "$GCS_CREDENTIALS" > /tmp/gcs-key.json
187189
gcloud auth activate-service-account --key-file=/tmp/gcs-key.json
188190
191+
# Upload all plot images (original + responsive variants)
189192
if [ -f "$IMPL_DIR/plot.png" ]; then
190-
gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
191-
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
192-
fi
193-
194-
if [ -f "$IMPL_DIR/plot_thumb.png" ]; then
195-
gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
196-
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
193+
gsutil -m -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR"/plot*.png "$IMPL_DIR"/plot*.webp "${STAGING_PATH}/"
194+
gsutil -m acl ch -u AllUsers:R "${STAGING_PATH}/plot*" 2>/dev/null || true
197195
fi
198196
199197
if [ -f "$IMPL_DIR/plot.html" ]; then

agentic/commands/update.md

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -268,11 +268,10 @@ For each library, copy the preview images to the implementations directory for G
268268

269269
```bash
270270
cp plots/{spec_id}/implementations/.update-preview/{library}/plot.png plots/{spec_id}/implementations/plot.png
271-
# Process images (thumbnail + optimization)
271+
# Process images (optimization)
272272
uv run python -m core.images process \
273273
plots/{spec_id}/implementations/plot.png \
274-
plots/{spec_id}/implementations/plot.png \
275-
plots/{spec_id}/implementations/plot_thumb.png
274+
plots/{spec_id}/implementations/plot.png
276275
```
277276

278277
Note: Since we process one library at a time for GCS upload, handle sequentially.
@@ -288,23 +287,18 @@ STAGING_PATH="gs://pyplots-images/staging/{spec_id}/{library}"
288287
gsutil cp plots/{spec_id}/implementations/plot.png "${STAGING_PATH}/plot.png"
289288
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
290289

291-
# Upload thumbnail
292-
gsutil cp plots/{spec_id}/implementations/plot_thumb.png "${STAGING_PATH}/plot_thumb.png"
293-
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
294-
295290
# Upload HTML if it exists (interactive libraries: plotly, bokeh, altair, highcharts, pygal, letsplot)
296291
if [ -f "plots/{spec_id}/implementations/.update-preview/{library}/plot.html" ]; then
297292
gsutil cp "plots/{spec_id}/implementations/.update-preview/{library}/plot.html" "${STAGING_PATH}/plot.html"
298293
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.html" 2>/dev/null || true
299294
fi
300295
```
301296

302-
Update `preview_url` and `preview_thumb` in the metadata YAML to point to the **production** URLs
297+
Update `preview_url` in the metadata YAML to point to the **production** URL
303298
(matching `impl-generate.yml` — production URLs are set from the start, `impl-merge.yml` promotes
304299
GCS files from staging to production on merge):
305300

306301
- `preview_url`: `https://storage.googleapis.com/pyplots-images/plots/{spec_id}/{library}/plot.png`
307-
- `preview_thumb`: `https://storage.googleapis.com/pyplots-images/plots/{spec_id}/{library}/plot_thumb.png`
308302

309303
#### 6f. Clean Up Preview Directory
310304

@@ -637,8 +631,7 @@ Generate thumbnail and optimize:
637631
```bash
638632
uv run python -m core.images process \
639633
plots/{SPEC_ID}/implementations/.update-preview/{LIBRARY}/plot.png \
640-
plots/{SPEC_ID}/implementations/.update-preview/{LIBRARY}/plot.png \
641-
plots/{SPEC_ID}/implementations/.update-preview/{LIBRARY}/plot_thumb.png
634+
plots/{SPEC_ID}/implementations/.update-preview/{LIBRARY}/plot.png
642635
```
643636

644637
### Step 7: Quality Evaluation & Local Repair Loop

agentic/docs/project-guide.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,6 @@ library_version: "3.10.0"
219219
220220
# Previews
221221
preview_url: https://storage.googleapis.com/pyplots-images/plots/scatter-basic/matplotlib/plot.png
222-
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/scatter-basic/matplotlib/plot_thumb.png
223222
preview_html: null
224223
225224
# Quality

api/mcp/server.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,6 @@ async def get_spec_detail(spec_id: str) -> dict[str, Any]:
274274
library_id=impl.library.id,
275275
library_name=impl.library.name,
276276
preview_url=impl.preview_url,
277-
preview_thumb=impl.preview_thumb,
278277
preview_html=impl.preview_html,
279278
quality_score=impl.quality_score,
280279
code=impl.code,
@@ -366,7 +365,6 @@ async def get_implementation(spec_id: str, library: str) -> dict[str, Any]:
366365
library_id=impl.library.id,
367366
library_name=impl.library.name,
368367
preview_url=impl.preview_url,
369-
preview_thumb=impl.preview_thumb,
370368
preview_html=impl.preview_html,
371369
quality_score=impl.quality_score,
372370
code=impl.code,

api/routers/libraries.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ async def get_library_images(library_id: str, db: AsyncSession = Depends(require
7575
library_id: The library ID (e.g., 'matplotlib', 'seaborn')
7676
7777
Returns:
78-
List of images with spec_id, preview_url, thumb, and html
78+
List of images with spec_id, preview_url, and html
7979
"""
8080

8181
# Validate library_id
@@ -99,7 +99,6 @@ async def get_library_images(library_id: str, db: AsyncSession = Depends(require
9999
"spec_id": spec.id,
100100
"library": impl.library_id,
101101
"url": impl.preview_url,
102-
"thumb": impl.preview_thumb,
103102
"html": impl.preview_html,
104103
"code": strip_noqa_comments(impl.code),
105104
}

api/routers/og_images.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,24 @@ def _get_http_client() -> httpx.AsyncClient:
6868
global _http_client
6969
if _http_client is None or _http_client.is_closed:
7070
_http_client = httpx.AsyncClient(
71-
timeout=30.0,
72-
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
71+
timeout=30.0, limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
7372
)
7473
return _http_client
7574

7675

7776
async def _fetch_image(url: str) -> bytes:
78-
"""Fetch an image from a URL using the shared HTTP client."""
79-
response = await _get_http_client().get(url)
77+
"""Fetch an image from a URL, trying the 800px variant first for efficiency."""
78+
client = _get_http_client()
79+
# Prefer smaller responsive variant for OG collage (each slot is ~400px wide)
80+
if url and url.endswith("/plot.png"):
81+
small_url = url.replace("/plot.png", "/plot_800.png")
82+
try:
83+
response = await client.get(small_url)
84+
response.raise_for_status()
85+
return response.content
86+
except Exception:
87+
pass # Fall back to original
88+
response = await client.get(url)
8089
response.raise_for_status()
8190
return response.content
8291

@@ -167,10 +176,8 @@ async def get_spec_collage_image(
167176
selected_impls = sorted_impls[:6]
168177

169178
try:
170-
# Fetch all images in parallel — prefer thumbnails (smaller, faster)
171-
images = list(
172-
await asyncio.gather(*[_fetch_image(impl.preview_thumb or impl.preview_url) for impl in selected_impls])
173-
)
179+
# Fetch all images in parallel
180+
images = list(await asyncio.gather(*[_fetch_image(impl.preview_url) for impl in selected_impls]))
174181
labels = [f"{spec_id} · {impl.library_id}" for impl in selected_impls]
175182

176183
# Create collage

api/routers/plots.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ def _collect_all_images(all_specs: list) -> list[dict]:
328328
all_specs: List of Spec objects
329329
330330
Returns:
331-
List of image dicts with spec_id, library, quality, url, thumb, html, and title
331+
List of image dicts with spec_id, library, quality, url, html, and title
332332
"""
333333
all_images: list[dict] = []
334334
for spec_obj in all_specs:
@@ -342,7 +342,6 @@ def _collect_all_images(all_specs: list) -> list[dict]:
342342
"library": impl.library_id,
343343
"quality": impl.quality_score,
344344
"url": impl.preview_url,
345-
"thumb": impl.preview_thumb,
346345
"html": impl.preview_html,
347346
"title": spec_obj.title,
348347
}

api/routers/specs.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
9494
library_id=impl.library_id,
9595
library_name=impl.library.name if impl.library else impl.library_id,
9696
preview_url=impl.preview_url,
97-
preview_thumb=impl.preview_thumb,
9897
preview_html=impl.preview_html,
9998
quality_score=impl.quality_score,
10099
code=strip_noqa_comments(impl.code),
@@ -135,7 +134,7 @@ async def get_spec_images(spec_id: str, db: AsyncSession = Depends(require_db)):
135134
"""
136135
Get plot images for a specification across all libraries.
137136
138-
Returns preview_url, preview_thumb, and preview_html from database.
137+
Returns preview_url and preview_html from database.
139138
"""
140139

141140
key = cache_key("spec_images", spec_id)
@@ -153,7 +152,7 @@ async def get_spec_images(spec_id: str, db: AsyncSession = Depends(require_db)):
153152
raise_not_found("Spec with implementations", spec_id)
154153

155154
images = [
156-
{"library": impl.library_id, "url": impl.preview_url, "thumb": impl.preview_thumb, "html": impl.preview_html}
155+
{"library": impl.library_id, "url": impl.preview_url, "html": impl.preview_html}
157156
for impl in spec.impls
158157
if impl.preview_url # Only include if there's a preview
159158
]

api/schemas.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ class ImplementationResponse(BaseModel):
1515
library_id: str
1616
library_name: str
1717
preview_url: str | None = None
18-
preview_thumb: str | None = None
1918
preview_html: str | None = None
2019
quality_score: float | None = None
2120
code: str | None = None
@@ -66,7 +65,6 @@ class ImageResponse(BaseModel):
6665
spec_id: str
6766
library: str
6867
url: str | None = None
69-
thumb: str | None = None
7068
html: str | None = None
7169
code: str | None = None
7270

@@ -93,7 +91,7 @@ class FilteredPlotsResponse(BaseModel):
9391
"""Response for filtered plots endpoint."""
9492

9593
total: int
96-
images: list[dict[str, Any]] # Image dicts with spec_id, library, url, thumb, etc.
94+
images: list[dict[str, Any]] # Image dicts with spec_id, library, url, etc.
9795
counts: dict[str, dict[str, int]] # Category -> value -> count
9896
globalCounts: dict[str, dict[str, int]] # Same structure for global counts
9997
orCounts: list[dict[str, int]] # Per-group OR counts

0 commit comments

Comments
 (0)