Skip to content

Commit 8a7f810

Browse files
committed
feat: responsive image delivery with multi-size PNG + WebP (#5191)
Backend: add create_responsive_variants() to generate 400/800/1200px PNGs and WebPs plus full-size WebP from source plot.png. Frontend: replace <img> with <picture> + srcSet/sizes in ImageCard, SpecDetailView, and SpecOverview. Browser auto-selects optimal size based on viewport and DPR. Falls back to existing thumb/url if responsive variants are not yet available. https://claude.ai/code/session_01WUQ7f2vcScXKtqLKgfdRtw
1 parent 3d4919b commit 8a7f810

5 files changed

Lines changed: 252 additions & 25 deletions

File tree

app/src/components/ImageCard.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { memo, useState, useCallback } from 'react';
22
import Box from '@mui/material/Box';
33
import Card from '@mui/material/Card';
4-
import CardMedia from '@mui/material/CardMedia';
54
import Typography from '@mui/material/Typography';
65
import Tooltip from '@mui/material/Tooltip';
76
import Link from '@mui/material/Link';
@@ -15,6 +14,7 @@ import { useTheme } from '@mui/material/styles';
1514
import type { PlotImage } from '../types';
1615
import { BATCH_SIZE, type ImageSize } from '../constants';
1716
import { useCodeFetch } from '../hooks';
17+
import { buildSrcSet, getResponsiveSizes, getFallbackSrc } from '../utils/responsiveImage';
1818

1919
// Library abbreviations for compact mode
2020
const LIBRARY_ABBR: Record<string, string> = {
@@ -147,23 +147,50 @@ export const ImageCard = memo(function ImageCard({
147147
},
148148
}}
149149
>
150-
<CardMedia
151-
component="img"
152-
loading={index < BATCH_SIZE ? 'eager' : 'lazy'}
153-
fetchPriority={index === 0 ? 'high' : undefined}
154-
image={image.thumb || image.url}
155-
alt={viewMode === 'library' ? `${image.spec_id} - ${image.library}` : `${selectedSpec} - ${image.library}`}
150+
<Box
151+
component="picture"
156152
sx={{
153+
display: 'block',
157154
width: '100%',
158155
aspectRatio: '16 / 10',
159-
objectFit: 'contain',
160156
bgcolor: '#fff',
161157
}}
162-
onError={(e) => {
163-
const target = e.target as HTMLImageElement;
164-
target.style.display = 'none';
165-
}}
166-
/>
158+
>
159+
<source
160+
type="image/webp"
161+
srcSet={buildSrcSet(image.url, 'webp')}
162+
sizes={getResponsiveSizes(imageSize)}
163+
/>
164+
<source
165+
type="image/png"
166+
srcSet={buildSrcSet(image.url, 'png')}
167+
sizes={getResponsiveSizes(imageSize)}
168+
/>
169+
<Box
170+
component="img"
171+
loading={index < BATCH_SIZE ? 'eager' : 'lazy'}
172+
fetchPriority={index === 0 ? 'high' : undefined}
173+
src={getFallbackSrc(image.url)}
174+
alt={viewMode === 'library' ? `${image.spec_id} - ${image.library}` : `${selectedSpec} - ${image.library}`}
175+
width={800}
176+
height={500}
177+
sx={{
178+
width: '100%',
179+
aspectRatio: '16 / 10',
180+
objectFit: 'contain',
181+
}}
182+
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
183+
const target = e.target as HTMLImageElement;
184+
// Fallback to original thumb/url if responsive variant not available
185+
if (!target.dataset.fallback) {
186+
target.dataset.fallback = '1';
187+
target.src = image.thumb || image.url;
188+
} else {
189+
target.style.display = 'none';
190+
}
191+
}}
192+
/>
193+
</Box>
167194
{/* Copy button - appears on hover */}
168195
<IconButton
169196
onClick={handleCopyCode}

app/src/components/SpecDetailView.tsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
1515
import CheckIcon from '@mui/icons-material/Check';
1616

1717
import type { Implementation } from '../types';
18+
import { buildSrcSet } from '../utils/responsiveImage';
1819

1920
interface SpecDetailViewProps {
2021
specId: string;
@@ -84,17 +85,44 @@ export function SpecDetailView({
8485
)}
8586
{currentImpl?.preview_url && (
8687
<Box
87-
component="img"
88-
src={currentImpl.preview_url}
89-
alt={`${specTitle} - ${selectedLibrary}`}
90-
onLoad={onImageLoad}
88+
component="picture"
9189
sx={{
9290
width: '100%',
9391
height: '100%',
94-
objectFit: 'contain',
9592
display: imageLoaded ? 'block' : 'none',
9693
}}
97-
/>
94+
>
95+
<source
96+
type="image/webp"
97+
srcSet={buildSrcSet(currentImpl.preview_url, 'webp')}
98+
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
99+
/>
100+
<source
101+
type="image/png"
102+
srcSet={buildSrcSet(currentImpl.preview_url, 'png')}
103+
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
104+
/>
105+
<Box
106+
component="img"
107+
src={`${currentImpl.preview_url.replace(/\.png$/, '')}_1200.png`}
108+
alt={`${specTitle} - ${selectedLibrary}`}
109+
onLoad={onImageLoad}
110+
width={1200}
111+
height={750}
112+
sx={{
113+
width: '100%',
114+
height: '100%',
115+
objectFit: 'contain',
116+
}}
117+
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
118+
const target = e.target as HTMLImageElement;
119+
if (!target.dataset.fallback) {
120+
target.dataset.fallback = '1';
121+
target.src = currentImpl.preview_url!;
122+
}
123+
}}
124+
/>
125+
</Box>
98126
)}
99127

100128
{/* Action Buttons (top-right) - stop propagation */}

app/src/components/SpecOverview.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
1818
import CheckIcon from '@mui/icons-material/Check';
1919

2020
import type { Implementation } from '../types';
21+
import { buildSrcSet } from '../utils/responsiveImage';
2122

2223
interface LibraryMeta {
2324
id: string;
@@ -141,18 +142,46 @@ function ImplementationCard({
141142
},
142143
}}
143144
>
144-
{impl.preview_thumb || impl.preview_url ? (
145+
{impl.preview_url ? (
145146
<Box
146-
component="img"
147-
src={impl.preview_thumb || impl.preview_url}
148-
alt={`${specTitle} - ${impl.library_id}`}
147+
component="picture"
149148
sx={{
149+
display: 'block',
150150
width: '100%',
151151
aspectRatio: '16/10',
152-
objectFit: 'contain',
153152
bgcolor: '#fff',
154153
}}
155-
/>
154+
>
155+
<source
156+
type="image/webp"
157+
srcSet={buildSrcSet(impl.preview_url, 'webp')}
158+
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
159+
/>
160+
<source
161+
type="image/png"
162+
srcSet={buildSrcSet(impl.preview_url, 'png')}
163+
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
164+
/>
165+
<Box
166+
component="img"
167+
src={`${impl.preview_url.replace(/\.png$/, '')}_800.png`}
168+
alt={`${specTitle} - ${impl.library_id}`}
169+
width={800}
170+
height={500}
171+
sx={{
172+
width: '100%',
173+
aspectRatio: '16/10',
174+
objectFit: 'contain',
175+
}}
176+
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
177+
const target = e.target as HTMLImageElement;
178+
if (!target.dataset.fallback) {
179+
target.dataset.fallback = '1';
180+
target.src = impl.preview_thumb || impl.preview_url!;
181+
}
182+
}}
183+
/>
184+
</Box>
156185
) : (
157186
<Skeleton variant="rectangular" sx={{ width: '100%', aspectRatio: '16/10' }} />
158187
)}

app/src/utils/responsiveImage.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Responsive image utilities for multi-size PNG + WebP delivery (issue #5191).
3+
*
4+
* All variant URLs are derived by convention from the base plot.png URL:
5+
* plot.png -> plot.webp, plot_1200.png, plot_1200.webp, plot_800.png, etc.
6+
*/
7+
8+
import type { ImageSize } from '../constants';
9+
10+
const RESPONSIVE_SIZES = [400, 800, 1200] as const;
11+
12+
/**
13+
* Derive the base path (without extension) from a plot URL.
14+
* e.g. ".../plots/scatter-basic/matplotlib/plot.png" -> ".../plots/scatter-basic/matplotlib/plot"
15+
*/
16+
function getBasePath(url: string): string {
17+
return url.replace(/\.png$/, '');
18+
}
19+
20+
/**
21+
* Build a srcSet string for <source> or <img> elements.
22+
* Generates entries like: ".../plot_400.webp 400w, .../plot_800.webp 800w, .../plot_1200.webp 1200w"
23+
*/
24+
export function buildSrcSet(url: string, format: 'webp' | 'png'): string {
25+
const base = getBasePath(url);
26+
return RESPONSIVE_SIZES
27+
.map((w) => `${base}_${w}.${format} ${w}w`)
28+
.join(', ');
29+
}
30+
31+
/**
32+
* Get the sizes attribute based on the view mode.
33+
*
34+
* Normal mode: fewer, larger cards.
35+
* Compact mode: more, smaller cards (roughly half the width).
36+
*/
37+
export function getResponsiveSizes(imageSize: ImageSize): string {
38+
if (imageSize === 'compact') {
39+
return '(max-width: 600px) 50vw, (max-width: 1200px) 25vw, 17vw';
40+
}
41+
return '(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw';
42+
}
43+
44+
/**
45+
* Get the fallback image URL (plot_800.png - good middle ground).
46+
*/
47+
export function getFallbackSrc(url: string): string {
48+
return `${getBasePath(url)}_800.png`;
49+
}

core/images.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424

2525
logger = logging.getLogger(__name__)
2626

27+
# Responsive image sizes and formats (issue #5191)
28+
RESPONSIVE_SIZES = [1200, 800, 400]
29+
RESPONSIVE_FORMATS: list[tuple[str, str, dict]] = [
30+
("png", "PNG", {}),
31+
("webp", "WEBP", {"quality": 80}),
32+
]
33+
WEBP_FULL_QUALITY = 85
34+
2735
# GCS bucket for static assets (fonts)
2836
GCS_STATIC_BUCKET = "pyplots-static"
2937
MONOLISA_FONT_PATH = "fonts/MonoLisaVariableNormal.ttf"
@@ -205,6 +213,81 @@ def process_plot_image(
205213
return result
206214

207215

216+
def create_responsive_variants(
217+
input_path: str | Path,
218+
output_dir: str | Path,
219+
sizes: list[int] | None = None,
220+
optimize: bool = True,
221+
) -> list[dict[str, str | int]]:
222+
"""Generate multi-size, multi-format image variants for responsive delivery.
223+
224+
Creates sized PNGs and WebPs (400/800/1200) plus a full-size WebP from the
225+
source image. File naming follows the convention expected by the frontend:
226+
plot_1200.png, plot_1200.webp, plot_800.png, plot_800.webp,
227+
plot_400.png, plot_400.webp, plot.webp
228+
229+
Args:
230+
input_path: Path to the source plot image (plot.png).
231+
output_dir: Directory where variants will be written.
232+
sizes: Override default RESPONSIVE_SIZES if needed.
233+
optimize: Whether to optimize PNGs with pngquant.
234+
235+
Returns:
236+
List of dicts, each with 'path', 'width', 'height', 'format'.
237+
"""
238+
input_path = Path(input_path)
239+
output_dir = Path(output_dir)
240+
output_dir.mkdir(parents=True, exist_ok=True)
241+
242+
img = Image.open(input_path)
243+
if img.mode in ("RGBA", "P"):
244+
img = img.convert("RGB")
245+
246+
results: list[dict[str, str | int]] = []
247+
target_sizes = sizes or RESPONSIVE_SIZES
248+
249+
# Sized variants (e.g. plot_1200.png, plot_1200.webp, plot_800.png, ...)
250+
for width in target_sizes:
251+
# Skip sizes larger than the original
252+
if width >= img.width:
253+
resized = img
254+
actual_width, actual_height = img.width, img.height
255+
else:
256+
ratio = width / img.width
257+
actual_width = width
258+
actual_height = int(img.height * ratio)
259+
resized = img.resize((actual_width, actual_height), Image.Resampling.LANCZOS)
260+
261+
for ext, fmt, opts in RESPONSIVE_FORMATS:
262+
out_path = output_dir / f"plot_{width}.{ext}"
263+
resized.save(out_path, fmt, optimize=True, **opts)
264+
265+
# Optimize PNG with pngquant
266+
if optimize and fmt == "PNG":
267+
optimize_png(out_path)
268+
269+
results.append({
270+
"path": str(out_path),
271+
"width": actual_width,
272+
"height": actual_height,
273+
"format": ext,
274+
})
275+
logger.info("Created %s (%dx%d)", out_path.name, actual_width, actual_height)
276+
277+
# Full-size WebP
278+
webp_path = output_dir / "plot.webp"
279+
img.save(webp_path, "WEBP", quality=WEBP_FULL_QUALITY)
280+
results.append({
281+
"path": str(webp_path),
282+
"width": img.width,
283+
"height": img.height,
284+
"format": "webp",
285+
})
286+
logger.info("Created plot.webp (%dx%d)", img.width, img.height)
287+
288+
return results
289+
290+
208291
# =============================================================================
209292
# Before/After Comparison Images
210293
# =============================================================================
@@ -758,13 +841,15 @@ def print_usage() -> None:
758841
print("Usage:")
759842
print(" python -m core.images thumbnail <input> <output> [width]")
760843
print(" python -m core.images process <input> <output> [thumb]")
844+
print(" python -m core.images responsive <input> <output_dir>")
761845
print(" python -m core.images brand <input> <output> [spec_id] [library]")
762846
print(" python -m core.images collage <output> <img1> [img2] [img3] [img4]")
763847
print(" python -m core.images compare <before> <after> <output> [spec_id] [library]")
764848
print("")
765849
print("Examples:")
766850
print(" python -m core.images thumbnail plot.png thumb.png 400")
767851
print(" python -m core.images process plot.png out.png thumb.png")
852+
print(" python -m core.images responsive plot.png ./output/")
768853
print(" python -m core.images brand plot.png og.png scatter-basic matplotlib")
769854
print(" python -m core.images collage og.png img1.png img2.png img3.png img4.png")
770855
print(" python -m core.images compare before.png after.png comparison.png area-basic matplotlib")
@@ -791,6 +876,15 @@ def print_usage() -> None:
791876
res = process_plot_image(input_file, output_file, thumb_file)
792877
print(f"Processed: {res}")
793878

879+
elif command == "responsive":
880+
if len(sys.argv) < 4:
881+
print_usage()
882+
input_file, output_dir = sys.argv[2], sys.argv[3]
883+
variants = create_responsive_variants(input_file, output_dir)
884+
print(f"Created {len(variants)} responsive variants in {output_dir}:")
885+
for v in variants:
886+
print(f" {Path(v['path']).name}: {v['width']}x{v['height']} ({v['format']})")
887+
794888
elif command == "brand":
795889
if len(sys.argv) < 4:
796890
print_usage()

0 commit comments

Comments
 (0)