Skip to content

Responsive Image Delivery: Multi-Size PNG + WebP (400/800/1200) #5191

@MarkusNeusinger

Description

@MarkusNeusinger

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:

  1. Rename: plot_thumb.pngplot_1200.png (GCS copy + delete)
  2. Generate from plot.png: plot_1200.webp, plot_800.png, plot_800.webp, plot_400.png, plot_400.webp, plot.webp
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestinfrastructureWorkflow, backend, or frontend issue

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions