diff --git a/.github/workflows/impl-generate.yml b/.github/workflows/impl-generate.yml
index 4f380d2f5c..5c3c0a0cd9 100644
--- a/.github/workflows/impl-generate.yml
+++ b/.github/workflows/impl-generate.yml
@@ -391,7 +391,6 @@ jobs:
'python_version': py_ver,
'library_version': lib_ver,
'preview_url': f'https://storage.googleapis.com/pyplots-images/plots/{spec}/{lib}/plot.png',
- 'preview_thumb': f'https://storage.googleapis.com/pyplots-images/plots/{spec}/{lib}/plot_thumb.png',
'preview_html': preview_html if preview_html != 'null' else None,
'quality_score': None,
'review': {'strengths': [], 'weaknesses': []}
@@ -448,13 +447,15 @@ jobs:
source .venv/bin/activate
- # Process PNG: optimize and create thumbnail
+ # Optimize PNG and generate responsive variants (400/800/1200 x png/webp + full webp)
python -m core.images process \
"$IMPL_DIR/plot.png" \
+ "$IMPL_DIR/plot.png"
+ python -m core.images responsive \
"$IMPL_DIR/plot.png" \
- "$IMPL_DIR/plot_thumb.png"
+ "$IMPL_DIR/"
- echo "::notice::Processed images: optimized + thumbnail created"
+ echo "::notice::Processed images: optimized + responsive variants created"
ls -la "$IMPL_DIR/"
# ========================================================================
@@ -532,25 +533,18 @@ jobs:
echo "$GCS_CREDENTIALS" > /tmp/gcs-key.json
gcloud auth activate-service-account --key-file=/tmp/gcs-key.json
- # Upload PNG (with watermark)
+ # Upload all plot images (original + responsive variants)
if [ -f "$IMPL_DIR/plot.png" ]; then
- gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
- gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
+ gsutil -m -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR"/plot*.png "$IMPL_DIR"/plot*.webp "${STAGING_PATH}/"
+ gsutil -m acl ch -u AllUsers:R "${STAGING_PATH}/plot*" 2>/dev/null || true
echo "png_url=${PUBLIC_URL}/plot.png" >> $GITHUB_OUTPUT
echo "uploaded=true" >> $GITHUB_OUTPUT
+ echo "::notice::Uploaded plot.png + responsive variants (PNG + WebP)"
else
echo "::error::No plot.png found - cannot continue without image"
exit 1
fi
- # Upload thumbnail
- if [ -f "$IMPL_DIR/plot_thumb.png" ]; then
- gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
- gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
- echo "thumb_url=${PUBLIC_URL}/plot_thumb.png" >> $GITHUB_OUTPUT
- echo "::notice::Uploaded thumbnail"
- fi
-
# Upload HTML (interactive libraries)
if [ -f "$IMPL_DIR/plot.html" ]; then
gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
diff --git a/.github/workflows/impl-repair.yml b/.github/workflows/impl-repair.yml
index 52c062b9bf..4b2d8f5232 100644
--- a/.github/workflows/impl-repair.yml
+++ b/.github/workflows/impl-repair.yml
@@ -159,13 +159,15 @@ jobs:
source .venv/bin/activate
- # Process PNG: optimize and create thumbnail
+ # Optimize PNG and generate responsive variants
python -m core.images process \
"$IMPL_DIR/plot.png" \
+ "$IMPL_DIR/plot.png"
+ python -m core.images responsive \
"$IMPL_DIR/plot.png" \
- "$IMPL_DIR/plot_thumb.png"
+ "$IMPL_DIR/"
- echo "::notice::Processed images: optimized + thumbnail created"
+ echo "::notice::Processed images: optimized + responsive variants created"
ls -la "$IMPL_DIR/"
- name: Upload repaired plot to GCS staging
@@ -186,14 +188,10 @@ jobs:
echo "$GCS_CREDENTIALS" > /tmp/gcs-key.json
gcloud auth activate-service-account --key-file=/tmp/gcs-key.json
+ # Upload all plot images (original + responsive variants)
if [ -f "$IMPL_DIR/plot.png" ]; then
- gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
- gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
- fi
-
- if [ -f "$IMPL_DIR/plot_thumb.png" ]; then
- gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
- gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
+ gsutil -m -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR"/plot*.png "$IMPL_DIR"/plot*.webp "${STAGING_PATH}/"
+ gsutil -m acl ch -u AllUsers:R "${STAGING_PATH}/plot*" 2>/dev/null || true
fi
if [ -f "$IMPL_DIR/plot.html" ]; then
diff --git a/agentic/commands/update.md b/agentic/commands/update.md
index b70484a6e1..a36fe3f98b 100644
--- a/agentic/commands/update.md
+++ b/agentic/commands/update.md
@@ -268,11 +268,10 @@ For each library, copy the preview images to the implementations directory for G
```bash
cp plots/{spec_id}/implementations/.update-preview/{library}/plot.png plots/{spec_id}/implementations/plot.png
-# Process images (thumbnail + optimization)
+# Process images (optimization)
uv run python -m core.images process \
plots/{spec_id}/implementations/plot.png \
- plots/{spec_id}/implementations/plot.png \
- plots/{spec_id}/implementations/plot_thumb.png
+ plots/{spec_id}/implementations/plot.png
```
Note: Since we process one library at a time for GCS upload, handle sequentially.
@@ -288,10 +287,6 @@ STAGING_PATH="gs://pyplots-images/staging/{spec_id}/{library}"
gsutil cp plots/{spec_id}/implementations/plot.png "${STAGING_PATH}/plot.png"
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
-# Upload thumbnail
-gsutil cp plots/{spec_id}/implementations/plot_thumb.png "${STAGING_PATH}/plot_thumb.png"
-gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
-
# Upload HTML if it exists (interactive libraries: plotly, bokeh, altair, highcharts, pygal, letsplot)
if [ -f "plots/{spec_id}/implementations/.update-preview/{library}/plot.html" ]; then
gsutil cp "plots/{spec_id}/implementations/.update-preview/{library}/plot.html" "${STAGING_PATH}/plot.html"
@@ -299,12 +294,11 @@ if [ -f "plots/{spec_id}/implementations/.update-preview/{library}/plot.html" ];
fi
```
-Update `preview_url` and `preview_thumb` in the metadata YAML to point to the **production** URLs
+Update `preview_url` in the metadata YAML to point to the **production** URL
(matching `impl-generate.yml` — production URLs are set from the start, `impl-merge.yml` promotes
GCS files from staging to production on merge):
- `preview_url`: `https://storage.googleapis.com/pyplots-images/plots/{spec_id}/{library}/plot.png`
-- `preview_thumb`: `https://storage.googleapis.com/pyplots-images/plots/{spec_id}/{library}/plot_thumb.png`
#### 6f. Clean Up Preview Directory
@@ -637,8 +631,7 @@ Generate thumbnail and optimize:
```bash
uv run python -m core.images process \
plots/{SPEC_ID}/implementations/.update-preview/{LIBRARY}/plot.png \
- plots/{SPEC_ID}/implementations/.update-preview/{LIBRARY}/plot.png \
- plots/{SPEC_ID}/implementations/.update-preview/{LIBRARY}/plot_thumb.png
+ plots/{SPEC_ID}/implementations/.update-preview/{LIBRARY}/plot.png
```
### Step 7: Quality Evaluation & Local Repair Loop
diff --git a/agentic/docs/project-guide.md b/agentic/docs/project-guide.md
index 406644165c..b82c161b61 100644
--- a/agentic/docs/project-guide.md
+++ b/agentic/docs/project-guide.md
@@ -219,7 +219,6 @@ library_version: "3.10.0"
# Previews
preview_url: https://storage.googleapis.com/pyplots-images/plots/scatter-basic/matplotlib/plot.png
-preview_thumb: https://storage.googleapis.com/pyplots-images/plots/scatter-basic/matplotlib/plot_thumb.png
preview_html: null
# Quality
diff --git a/api/mcp/server.py b/api/mcp/server.py
index e8c3843466..7c422d3bf5 100644
--- a/api/mcp/server.py
+++ b/api/mcp/server.py
@@ -274,7 +274,6 @@ async def get_spec_detail(spec_id: str) -> dict[str, Any]:
library_id=impl.library.id,
library_name=impl.library.name,
preview_url=impl.preview_url,
- preview_thumb=impl.preview_thumb,
preview_html=impl.preview_html,
quality_score=impl.quality_score,
code=impl.code,
@@ -366,7 +365,6 @@ async def get_implementation(spec_id: str, library: str) -> dict[str, Any]:
library_id=impl.library.id,
library_name=impl.library.name,
preview_url=impl.preview_url,
- preview_thumb=impl.preview_thumb,
preview_html=impl.preview_html,
quality_score=impl.quality_score,
code=impl.code,
diff --git a/api/routers/libraries.py b/api/routers/libraries.py
index 17bac85de8..58cca151ba 100644
--- a/api/routers/libraries.py
+++ b/api/routers/libraries.py
@@ -75,7 +75,7 @@ async def get_library_images(library_id: str, db: AsyncSession = Depends(require
library_id: The library ID (e.g., 'matplotlib', 'seaborn')
Returns:
- List of images with spec_id, preview_url, thumb, and html
+ List of images with spec_id, preview_url, and html
"""
# Validate library_id
@@ -99,7 +99,6 @@ async def get_library_images(library_id: str, db: AsyncSession = Depends(require
"spec_id": spec.id,
"library": impl.library_id,
"url": impl.preview_url,
- "thumb": impl.preview_thumb,
"html": impl.preview_html,
"code": strip_noqa_comments(impl.code),
}
diff --git a/api/routers/og_images.py b/api/routers/og_images.py
index ae3c283a08..e3493da486 100644
--- a/api/routers/og_images.py
+++ b/api/routers/og_images.py
@@ -68,15 +68,24 @@ def _get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(
- timeout=30.0,
- limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
+ timeout=30.0, limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
)
return _http_client
async def _fetch_image(url: str) -> bytes:
- """Fetch an image from a URL using the shared HTTP client."""
- response = await _get_http_client().get(url)
+ """Fetch an image from a URL, trying the 800px variant first for efficiency."""
+ client = _get_http_client()
+ # Prefer smaller responsive variant for OG collage (each slot is ~400px wide)
+ if url and url.endswith("/plot.png"):
+ small_url = url.replace("/plot.png", "/plot_800.png")
+ try:
+ response = await client.get(small_url)
+ response.raise_for_status()
+ return response.content
+ except Exception:
+ pass # Fall back to original
+ response = await client.get(url)
response.raise_for_status()
return response.content
@@ -167,10 +176,8 @@ async def get_spec_collage_image(
selected_impls = sorted_impls[:6]
try:
- # Fetch all images in parallel — prefer thumbnails (smaller, faster)
- images = list(
- await asyncio.gather(*[_fetch_image(impl.preview_thumb or impl.preview_url) for impl in selected_impls])
- )
+ # Fetch all images in parallel
+ images = list(await asyncio.gather(*[_fetch_image(impl.preview_url) for impl in selected_impls]))
labels = [f"{spec_id} · {impl.library_id}" for impl in selected_impls]
# Create collage
diff --git a/api/routers/plots.py b/api/routers/plots.py
index 210885ca9d..9053296864 100644
--- a/api/routers/plots.py
+++ b/api/routers/plots.py
@@ -328,7 +328,7 @@ def _collect_all_images(all_specs: list) -> list[dict]:
all_specs: List of Spec objects
Returns:
- List of image dicts with spec_id, library, quality, url, thumb, html, and title
+ List of image dicts with spec_id, library, quality, url, html, and title
"""
all_images: list[dict] = []
for spec_obj in all_specs:
@@ -342,7 +342,6 @@ def _collect_all_images(all_specs: list) -> list[dict]:
"library": impl.library_id,
"quality": impl.quality_score,
"url": impl.preview_url,
- "thumb": impl.preview_thumb,
"html": impl.preview_html,
"title": spec_obj.title,
}
diff --git a/api/routers/specs.py b/api/routers/specs.py
index 58b59a0a87..df0dc76c7f 100644
--- a/api/routers/specs.py
+++ b/api/routers/specs.py
@@ -94,7 +94,6 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
library_id=impl.library_id,
library_name=impl.library.name if impl.library else impl.library_id,
preview_url=impl.preview_url,
- preview_thumb=impl.preview_thumb,
preview_html=impl.preview_html,
quality_score=impl.quality_score,
code=strip_noqa_comments(impl.code),
@@ -135,7 +134,7 @@ async def get_spec_images(spec_id: str, db: AsyncSession = Depends(require_db)):
"""
Get plot images for a specification across all libraries.
- Returns preview_url, preview_thumb, and preview_html from database.
+ Returns preview_url and preview_html from database.
"""
key = cache_key("spec_images", spec_id)
@@ -153,7 +152,7 @@ async def get_spec_images(spec_id: str, db: AsyncSession = Depends(require_db)):
raise_not_found("Spec with implementations", spec_id)
images = [
- {"library": impl.library_id, "url": impl.preview_url, "thumb": impl.preview_thumb, "html": impl.preview_html}
+ {"library": impl.library_id, "url": impl.preview_url, "html": impl.preview_html}
for impl in spec.impls
if impl.preview_url # Only include if there's a preview
]
diff --git a/api/schemas.py b/api/schemas.py
index acac9b9167..71493c8e93 100644
--- a/api/schemas.py
+++ b/api/schemas.py
@@ -15,7 +15,6 @@ class ImplementationResponse(BaseModel):
library_id: str
library_name: str
preview_url: str | None = None
- preview_thumb: str | None = None
preview_html: str | None = None
quality_score: float | None = None
code: str | None = None
@@ -66,7 +65,6 @@ class ImageResponse(BaseModel):
spec_id: str
library: str
url: str | None = None
- thumb: str | None = None
html: str | None = None
code: str | None = None
@@ -93,7 +91,7 @@ class FilteredPlotsResponse(BaseModel):
"""Response for filtered plots endpoint."""
total: int
- images: list[dict[str, Any]] # Image dicts with spec_id, library, url, thumb, etc.
+ images: list[dict[str, Any]] # Image dicts with spec_id, library, url, etc.
counts: dict[str, dict[str, int]] # Category -> value -> count
globalCounts: dict[str, dict[str, int]] # Same structure for global counts
orCounts: list[dict[str, int]] # Per-group OR counts
diff --git a/app/src/components/ImageCard.test.tsx b/app/src/components/ImageCard.test.tsx
index 37a503b9dd..76f2f16342 100644
--- a/app/src/components/ImageCard.test.tsx
+++ b/app/src/components/ImageCard.test.tsx
@@ -37,17 +37,10 @@ describe('ImageCard', () => {
expect(screen.getByText('matplotlib')).toBeInTheDocument();
});
- it('renders the plot image', () => {
+ it('renders the plot image with responsive fallback src', () => {
render();
const img = screen.getByRole('img');
- expect(img).toHaveAttribute('src', 'https://example.com/plot.png');
- });
-
- it('uses thumb URL when available', () => {
- const imageWithThumb = { ...baseImage, thumb: 'https://example.com/thumb.png' };
- render();
- const img = screen.getByRole('img');
- expect(img).toHaveAttribute('src', 'https://example.com/thumb.png');
+ expect(img).toHaveAttribute('src', 'https://example.com/plot_800.png');
});
it('calls onClick when card is clicked', async () => {
diff --git a/app/src/components/ImageCard.tsx b/app/src/components/ImageCard.tsx
index 3ec56439f5..bbac211bae 100644
--- a/app/src/components/ImageCard.tsx
+++ b/app/src/components/ImageCard.tsx
@@ -1,7 +1,6 @@
import { memo, useState, useCallback } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
-import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import Link from '@mui/material/Link';
@@ -15,6 +14,7 @@ import { useTheme } from '@mui/material/styles';
import type { PlotImage } from '../types';
import { BATCH_SIZE, type ImageSize } from '../constants';
import { useCodeFetch } from '../hooks';
+import { buildSrcSet, getResponsiveSizes, getFallbackSrc } from '../utils/responsiveImage';
// Library abbreviations for compact mode
const LIBRARY_ABBR: Record = {
@@ -147,23 +147,50 @@ export const ImageCard = memo(function ImageCard({
},
}}
>
- {
- const target = e.target as HTMLImageElement;
- target.style.display = 'none';
- }}
- />
+ >
+
+
+ ) => {
+ const target = e.target as HTMLImageElement;
+ // Fallback to original plot.png if responsive variant not available
+ if (!target.dataset.fallback) {
+ target.dataset.fallback = '1';
+ target.src = image.url;
+ } else {
+ target.style.display = 'none';
+ }
+ }}
+ />
+
{/* Copy button - appears on hover */}
+ >
+
+
+ ) => {
+ const target = e.target as HTMLImageElement;
+ if (!target.dataset.fallback) {
+ target.dataset.fallback = '1';
+ target.src = currentImpl.preview_url!;
+ }
+ }}
+ />
+
)}
{/* Action Buttons (top-right) - stop propagation */}
diff --git a/app/src/components/SpecOverview.tsx b/app/src/components/SpecOverview.tsx
index c925c36e8e..b4849f813d 100644
--- a/app/src/components/SpecOverview.tsx
+++ b/app/src/components/SpecOverview.tsx
@@ -18,6 +18,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import type { Implementation } from '../types';
+import { buildSrcSet } from '../utils/responsiveImage';
interface LibraryMeta {
id: string;
@@ -141,18 +142,46 @@ function ImplementationCard({
},
}}
>
- {impl.preview_thumb || impl.preview_url ? (
+ {impl.preview_url ? (
+ >
+
+
+ ) => {
+ const target = e.target as HTMLImageElement;
+ if (!target.dataset.fallback) {
+ target.dataset.fallback = '1';
+ target.src = impl.preview_url!;
+ }
+ }}
+ />
+
) : (
)}
diff --git a/app/src/pages/CatalogPage.tsx b/app/src/pages/CatalogPage.tsx
index 154f9a0097..ec055af506 100644
--- a/app/src/pages/CatalogPage.tsx
+++ b/app/src/pages/CatalogPage.tsx
@@ -8,6 +8,7 @@ import Fab from '@mui/material/Fab';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { API_URL, GITHUB_URL } from '../constants';
+import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage';
import { useAnalytics } from '../hooks';
import { useAppData, useHomeState } from '../hooks';
import { Breadcrumb, Footer } from '../components';
@@ -246,15 +247,39 @@ export function CatalogPage() {
>
{currentImage && (
+ component="picture"
+ sx={{ display: 'block', width: '100%', height: '100%' }}
+ >
+
+
+ ) => {
+ const target = e.target as HTMLImageElement;
+ if (!target.dataset.fallback) {
+ target.dataset.fallback = '1';
+ target.src = currentImage.url;
+ }
+ }}
+ />
+
)}
{/* Rotation hint badge */}
diff --git a/app/src/types/index.ts b/app/src/types/index.ts
index 121e0d1b8b..36724427db 100644
--- a/app/src/types/index.ts
+++ b/app/src/types/index.ts
@@ -3,7 +3,6 @@
export interface PlotImage {
library: string;
url: string;
- thumb?: string;
html?: string;
code?: string;
spec_id?: string;
@@ -118,7 +117,6 @@ export interface Implementation {
library_id: string;
library_name: string;
preview_url: string;
- preview_thumb?: string;
preview_html?: string;
quality_score: number | null;
code: string | null;
diff --git a/app/src/utils/responsiveImage.ts b/app/src/utils/responsiveImage.ts
new file mode 100644
index 0000000000..ae540e287e
--- /dev/null
+++ b/app/src/utils/responsiveImage.ts
@@ -0,0 +1,49 @@
+/**
+ * Responsive image utilities for multi-size PNG + WebP delivery (issue #5191).
+ *
+ * All variant URLs are derived by convention from the base plot.png URL:
+ * plot.png -> plot.webp, plot_1200.png, plot_1200.webp, plot_800.png, etc.
+ */
+
+import type { ImageSize } from '../constants';
+
+const RESPONSIVE_SIZES = [400, 800, 1200] as const;
+
+/**
+ * Derive the base path (without extension) from a plot URL.
+ * e.g. ".../plots/scatter-basic/matplotlib/plot.png" -> ".../plots/scatter-basic/matplotlib/plot"
+ */
+function getBasePath(url: string): string {
+ return url.replace(/\.png$/, '');
+}
+
+/**
+ * Build a srcSet string for or
elements.
+ * Generates entries like: ".../plot_400.webp 400w, .../plot_800.webp 800w, .../plot_1200.webp 1200w"
+ */
+export function buildSrcSet(url: string, format: 'webp' | 'png'): string {
+ const base = getBasePath(url);
+ return RESPONSIVE_SIZES
+ .map((w) => `${base}_${w}.${format} ${w}w`)
+ .join(', ');
+}
+
+/**
+ * Get the sizes attribute based on the view mode.
+ *
+ * Normal mode: fewer, larger cards.
+ * Compact mode: more, smaller cards (roughly half the width).
+ */
+export function getResponsiveSizes(imageSize: ImageSize): string {
+ if (imageSize === 'compact') {
+ return '(max-width: 600px) 50vw, (max-width: 1200px) 25vw, 17vw';
+ }
+ return '(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw';
+}
+
+/**
+ * Get the fallback image URL (plot_800.png - good middle ground).
+ */
+export function getFallbackSrc(url: string): string {
+ return `${getBasePath(url)}_800.png`;
+}
diff --git a/automation/scripts/sync_to_postgres.py b/automation/scripts/sync_to_postgres.py
index 7036269f84..2f38ce0139 100644
--- a/automation/scripts/sync_to_postgres.py
+++ b/automation/scripts/sync_to_postgres.py
@@ -327,7 +327,6 @@ def scan_plot_directory(plot_dir: Path) -> dict | None:
"code": code,
# Preview URLs (from metadata YAML, filled by workflow)
"preview_url": impl_meta.get("preview_url"),
- "preview_thumb": impl_meta.get("preview_thumb"),
"preview_html": impl_meta.get("preview_html"),
# Versions (from metadata YAML, filled by workflow)
"python_version": current.get("python_version") or impl_meta.get("python_version"),
@@ -371,7 +370,6 @@ def _chunked(iterable, size):
_IMPL_UPDATE_FIELDS = [
"code",
"preview_url",
- "preview_thumb",
"preview_html",
"python_version",
"library_version",
diff --git a/core/database/repositories.py b/core/database/repositories.py
index 9736facf20..edc781b74e 100644
--- a/core/database/repositories.py
+++ b/core/database/repositories.py
@@ -32,7 +32,6 @@
{
"code",
"preview_url",
- "preview_thumb",
"preview_html",
"python_version",
"library_version",
diff --git a/core/images.py b/core/images.py
index ea6bd334cd..26d282d9eb 100644
--- a/core/images.py
+++ b/core/images.py
@@ -24,6 +24,11 @@
logger = logging.getLogger(__name__)
+# Responsive image sizes and formats (issue #5191)
+RESPONSIVE_SIZES = [1200, 800, 400]
+RESPONSIVE_FORMATS: list[tuple[str, str, dict]] = [("png", "PNG", {}), ("webp", "WEBP", {"quality": 80})]
+WEBP_FULL_QUALITY = 85
+
# GCS bucket for static assets (fonts)
GCS_STATIC_BUCKET = "pyplots-static"
MONOLISA_FONT_PATH = "fonts/MonoLisaVariableNormal.ttf"
@@ -205,6 +210,69 @@ def process_plot_image(
return result
+def create_responsive_variants(
+ input_path: str | Path, output_dir: str | Path, sizes: list[int] | None = None, optimize: bool = True
+) -> list[dict[str, str | int]]:
+ """Generate multi-size, multi-format image variants for responsive delivery.
+
+ Creates sized PNGs and WebPs (400/800/1200) plus a full-size WebP from the
+ source image. File naming follows the convention expected by the frontend:
+ plot_1200.png, plot_1200.webp, plot_800.png, plot_800.webp,
+ plot_400.png, plot_400.webp, plot.webp
+
+ Args:
+ input_path: Path to the source plot image (plot.png).
+ output_dir: Directory where variants will be written.
+ sizes: Override default RESPONSIVE_SIZES if needed.
+ optimize: Whether to optimize PNGs with pngquant.
+
+ Returns:
+ List of dicts, each with 'path', 'width', 'height', 'format'.
+ """
+ input_path = Path(input_path)
+ output_dir = Path(output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ with Image.open(input_path) as src:
+ if src.mode in ("RGBA", "P"):
+ img = src.convert("RGB")
+ else:
+ img = src.copy()
+
+ results: list[dict[str, str | int]] = []
+ target_sizes = sizes or RESPONSIVE_SIZES
+
+ # Sized variants (e.g. plot_1200.png, plot_1200.webp, plot_800.png, ...)
+ for width in target_sizes:
+ # Skip sizes larger than the original
+ if width >= img.width:
+ continue
+ else:
+ ratio = width / img.width
+ actual_width = width
+ actual_height = int(img.height * ratio)
+ resized = img.resize((actual_width, actual_height), Image.Resampling.LANCZOS)
+
+ for ext, fmt, opts in RESPONSIVE_FORMATS:
+ out_path = output_dir / f"plot_{width}.{ext}"
+ resized.save(out_path, fmt, optimize=True, **opts)
+
+ # Optimize PNG with pngquant
+ if optimize and fmt == "PNG":
+ optimize_png(out_path)
+
+ results.append({"path": str(out_path), "width": actual_width, "height": actual_height, "format": ext})
+ logger.info("Created %s (%dx%d)", out_path.name, actual_width, actual_height)
+
+ # Full-size WebP
+ webp_path = output_dir / "plot.webp"
+ img.save(webp_path, "WEBP", quality=WEBP_FULL_QUALITY)
+ results.append({"path": str(webp_path), "width": img.width, "height": img.height, "format": "webp"})
+ logger.info("Created plot.webp (%dx%d)", img.width, img.height)
+
+ return results
+
+
# =============================================================================
# Before/After Comparison Images
# =============================================================================
@@ -758,6 +826,7 @@ def print_usage() -> None:
print("Usage:")
print(" python -m core.images thumbnail