Summary
Implement a multi-size, multi-format image delivery pipeline. All plot images stored in 3 sizes × 2 formats (PNG + WebP) in GCS, served responsively via <picture> + srcset. Rename existing plot_thumb.png to plot_1200.png for consistent naming.
Current State
- Full size:
plot.png (original)
- Thumbnail:
plot_thumb.png (1200px wide)
- Format: PNG only
- Serving: Single
<img> tag, no responsive sizing
- PageSpeed flags ~3,000 KiB potential savings from oversized images
Proposed GCS Structure
gs://pyplots-images/plots/{spec-id}/{library}/
├── plot.png # Original full size (existing, unchanged)
├── plot.webp # Original full size WebP (NEW)
├── plot_1200.png # 1200px wide (RENAMED from plot_thumb.png)
├── plot_1200.webp # 1200px wide WebP (NEW)
├── plot_800.png # 800px wide (NEW)
├── plot_800.webp # 800px wide WebP (NEW)
├── plot_400.png # 400px wide (NEW)
├── plot_400.webp # 400px wide WebP (NEW)
8 files per implementation (1 existing unchanged + 1 renamed + 6 new).
Why 400 / 800 / 1200?
Each size covers a ~2x range. The browser picks the optimal size based on viewport width AND device pixel ratio (DPR):
| Size |
Covers up to |
Primary users |
| 400 |
400px physical |
Mobile 1x DPR, desktop small cards at 1x |
| 800 |
800px physical |
Retina mobile (2x), tablet 2x, desktop normal — most users |
| 1200 |
1200px physical |
Retina desktop, ultrawide, spec detail view |
Real-world examples:
- iPhone (375px viewport, 2x DPR, 2 cards/row): needs ~360px → picks 400
- iPad (768px, 2x, 3 cards/row): needs ~500px → picks 800
- Retina MacBook (1440px, 2x, 3 cards/row): needs ~900px → picks 1200
- Desktop (1920px, 1x, 4 cards/row): needs ~450px → picks 800
Frontend Implementation
In ImageCard.tsx, replace <CardMedia component="img"> with a <picture> element using srcSet + sizes.
The sizes attribute must account for the view mode (normal vs compact), since compact mode shows twice as many columns → images are roughly half the width:
| Breakpoint |
Normal mode (cols) |
Image width |
Compact mode (cols) |
Image width |
| xs (<600px) |
1 |
~100vw |
2 |
~50vw |
| sm (600-900px) |
1 |
~100vw |
2 |
~50vw |
| md (900-1200px) |
2 |
~50vw |
4 |
~25vw |
| lg (1200-1536px) |
2 |
~50vw |
4 |
~25vw |
| xl (>1536px) |
3 |
~33vw |
6 |
~17vw |
// Derive all URLs from the base plot URL by convention
const base = image.url.replace(/\.png$/, ''); // e.g. .../plots/scatter-basic/matplotlib/plot
// sizes depends on view mode (normal = fewer larger cards, compact = more smaller cards)
const sizes = imageSize === 'compact'
? '(max-width: 600px) 50vw, (max-width: 1200px) 25vw, 17vw'
: '(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw';
<picture>
<source
type="image/webp"
srcSet={`${base}_400.webp 400w, ${base}_800.webp 800w, ${base}_1200.webp 1200w`}
sizes={sizes}
/>
<img
src={`${base}_800.png`}
srcSet={`${base}_400.png 400w, ${base}_800.png 800w, ${base}_1200.png 1200w`}
sizes={sizes}
loading={index < BATCH_SIZE ? 'eager' : 'lazy'}
fetchPriority={index === 0 ? 'high' : undefined}
width="800"
height="500"
alt={...}
/>
</picture>
The browser automatically picks the optimal size based on viewport width, DPR, AND the sizes hint. In compact mode on a 1920px 1x desktop, the browser calculates 1920 * 17% = 326px → picks plot_400.png. Same viewport in normal mode: 1920 * 33% = 634px → picks plot_800.png.
Backend / Pipeline Changes
1. Thumbnail Generation (core/images.py)
Replace current single-thumbnail logic with multi-size generation:
SIZES = [1200, 800, 400]
FORMATS = [("png", "PNG", {}), ("webp", "WEBP", {"quality": 80})]
for width in SIZES:
for ext, fmt, opts in FORMATS:
save_thumbnail(img, width=width, path=f"plot_{width}.{ext}", format=fmt, **opts)
# Full-size WebP
save_as_webp(img, path="plot.webp", quality=85)
2. GCS Upload (workflow scripts)
Update impl-generate.yml and impl-merge.yml to handle all 8 files (upload to staging, promote to production).
3. Migration / Backfill Script
One-time script for all existing ~2400 implementations:
- Rename:
plot_thumb.png → plot_1200.png (GCS copy + delete)
- Generate from plot.png:
plot_1200.webp, plot_800.png, plot_800.webp, plot_400.png, plot_400.webp, plot.webp
- Update metadata: Simplify
metadata/*.yaml — store only preview_url pointing to plot.png. Remove preview_thumb field (all sizes derivable from plot.png URL by convention)
4. API Response / Metadata
Only preview_url (pointing to plot.png) is stored in metadata. All variants are derived by convention:
plot.png → plot.webp (replace extension)
plot.png → plot_1200.png, plot_1200.webp (insert size, optionally replace ext)
plot.png → plot_800.png, plot_800.webp
plot.png → plot_400.png, plot_400.webp
Every implementation always has all 8 variants. No need to store individual URLs — one base URL is enough.
Expected Impact
- Image payload: ~3,000 KiB → ~300-600 KiB per page load (mobile gets 400px WebP instead of 1200px PNG)
- Mobile LCP: 4.4s → ~2.0-2.5s
- Mobile Performance: 83 → ~93-95
- WebP is ~25-35% smaller than PNG at equivalent quality
- 400px images are ~90% smaller than 1200px images
Out of Scope
- Changes to interactive plot HTML files
Summary
Implement a multi-size, multi-format image delivery pipeline. All plot images stored in 3 sizes × 2 formats (PNG + WebP) in GCS, served responsively via
<picture>+srcset. Rename existingplot_thumb.pngtoplot_1200.pngfor consistent naming.Current State
plot.png(original)plot_thumb.png(1200px wide)<img>tag, no responsive sizingProposed GCS Structure
8 files per implementation (1 existing unchanged + 1 renamed + 6 new).
Why 400 / 800 / 1200?
Each size covers a ~2x range. The browser picks the optimal size based on viewport width AND device pixel ratio (DPR):
Real-world examples:
Frontend Implementation
In
ImageCard.tsx, replace<CardMedia component="img">with a<picture>element usingsrcSet+sizes.The
sizesattribute must account for the view mode (normal vs compact), since compact mode shows twice as many columns → images are roughly half the width:The browser automatically picks the optimal size based on viewport width, DPR, AND the
sizeshint. In compact mode on a 1920px 1x desktop, the browser calculates 1920 * 17% = 326px → picksplot_400.png. Same viewport in normal mode: 1920 * 33% = 634px → picksplot_800.png.Backend / Pipeline Changes
1. Thumbnail Generation (
core/images.py)Replace current single-thumbnail logic with multi-size generation:
2. GCS Upload (workflow scripts)
Update
impl-generate.ymlandimpl-merge.ymlto handle all 8 files (upload to staging, promote to production).3. Migration / Backfill Script
One-time script for all existing ~2400 implementations:
plot_thumb.png→plot_1200.png(GCS copy + delete)plot_1200.webp,plot_800.png,plot_800.webp,plot_400.png,plot_400.webp,plot.webpmetadata/*.yaml— store onlypreview_urlpointing toplot.png. Removepreview_thumbfield (all sizes derivable fromplot.pngURL by convention)4. API Response / Metadata
Only
preview_url(pointing toplot.png) is stored in metadata. All variants are derived by convention:Every implementation always has all 8 variants. No need to store individual URLs — one base URL is enough.
Expected Impact
Out of Scope