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 [width]") print(" python -m core.images process [thumb]") + print(" python -m core.images responsive ") print(" python -m core.images brand [spec_id] [library]") print(" python -m core.images collage [img2] [img3] [img4]") print(" python -m core.images compare [spec_id] [library]") @@ -765,6 +834,7 @@ def print_usage() -> None: print("Examples:") print(" python -m core.images thumbnail plot.png thumb.png 400") print(" python -m core.images process plot.png out.png thumb.png") + print(" python -m core.images responsive plot.png ./output/") print(" python -m core.images brand plot.png og.png scatter-basic matplotlib") print(" python -m core.images collage og.png img1.png img2.png img3.png img4.png") print(" python -m core.images compare before.png after.png comparison.png area-basic matplotlib") @@ -791,6 +861,15 @@ def print_usage() -> None: res = process_plot_image(input_file, output_file, thumb_file) print(f"Processed: {res}") + elif command == "responsive": + if len(sys.argv) < 4: + print_usage() + input_file, output_dir = sys.argv[2], sys.argv[3] + variants = create_responsive_variants(input_file, output_dir) + print(f"Created {len(variants)} responsive variants in {output_dir}:") + for v in variants: + print(f" {Path(v['path']).name}: {v['width']}x{v['height']} ({v['format']})") + elif command == "brand": if len(sys.argv) < 4: print_usage() diff --git a/docs/reference/api.md b/docs/reference/api.md index 474556d596..444baa3f68 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -66,7 +66,6 @@ The pyplots API is a **FastAPI-based REST API** serving plot data to the fronten "library_id": "matplotlib", "library_name": "Matplotlib", "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_score": 92.0, "code": "import matplotlib.pyplot as plt...", @@ -98,7 +97,6 @@ The pyplots API is a **FastAPI-based REST API** serving plot data to the fronten { "library": "matplotlib", "url": "https://storage.googleapis.com/.../plot.png", - "thumb": "https://storage.googleapis.com/.../plot_thumb.png", "html": null } ] @@ -143,7 +141,6 @@ The pyplots API is a **FastAPI-based REST API** serving plot data to the fronten "spec_id": "scatter-basic", "library": "matplotlib", "url": "https://storage.googleapis.com/.../plot.png", - "thumb": "https://storage.googleapis.com/.../plot_thumb.png", "html": null, "code": "import matplotlib.pyplot as plt..." } @@ -191,7 +188,6 @@ The pyplots API is a **FastAPI-based REST API** serving plot data to the fronten "library": "matplotlib", "quality": 92, "url": "https://storage.googleapis.com/.../plot.png", - "thumb": "https://storage.googleapis.com/.../plot_thumb.png", "html": null } ], diff --git a/docs/reference/database.md b/docs/reference/database.md index 5f6ddb30c8..1c86d73635 100644 --- a/docs/reference/database.md +++ b/docs/reference/database.md @@ -118,7 +118,6 @@ CREATE TABLE impls -- Preview URLs (GCS) preview_url VARCHAR, -- Full PNG: gs://pyplots-images/plots/.../plot.png - preview_thumb VARCHAR, -- Thumbnail: gs://pyplots-images/plots/.../plot_thumb.png preview_html VARCHAR, -- Interactive: gs://pyplots-images/plots/.../plot.html -- Version info @@ -219,7 +218,6 @@ INSERT INTO libraries (id, name, version, documentation_url) VALUES - `library_id` - Which library - `code` - Full Python source code - `preview_url` - GCS URL to full-size image -- `preview_thumb` - GCS URL to thumbnail - `preview_html` - GCS URL to interactive HTML (optional) - `quality_score` - AI quality score (0-100) - `python_version` - Python version used for generation @@ -233,7 +231,6 @@ INSERT INTO libraries (id, name, version, documentation_url) VALUES "library_id": "matplotlib", "code": "import matplotlib.pyplot as plt\n...", "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, "python_version": "3.13", "library_version": "3.9.0", diff --git a/docs/reference/repository.md b/docs/reference/repository.md index 391f4dfdf4..705ffc6fe6 100644 --- a/docs/reference/repository.md +++ b/docs/reference/repository.md @@ -257,7 +257,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 @@ -331,7 +330,6 @@ Preview images are stored in Google Cloud Storage (not in repo): gs://pyplots-images/ ├── plots/{spec-id}/{library}/ # Production (after merge) │ ├── plot.png # Full-size optimized image -│ ├── plot_thumb.png # Thumbnail (600px width) │ └── plot.html # Optional (interactive libraries) │ └── staging/{spec-id}/{library}/ # Temp (during review) diff --git a/prompts/templates/library-metadata.yaml b/prompts/templates/library-metadata.yaml index a89af627ff..24c4e360df 100644 --- a/prompts/templates/library-metadata.yaml +++ b/prompts/templates/library-metadata.yaml @@ -6,7 +6,6 @@ specification_id: {specification-id} # Preview URLs (GCS - filled by workflow) preview_url: https://storage.googleapis.com/pyplots-images/plots/{specification-id}/{library}/plot.png -preview_thumb: https://storage.googleapis.com/pyplots-images/plots/{specification-id}/{library}/plot_thumb.png preview_html: null # For interactive libraries: plotly, bokeh, altair, highcharts, pygal, letsplot # Current live version diff --git a/prompts/templates/metadata.yaml b/prompts/templates/metadata.yaml index f7cdae1f49..e27b6d8464 100644 --- a/prompts/templates/metadata.yaml +++ b/prompts/templates/metadata.yaml @@ -18,8 +18,7 @@ python_version: null # e.g., "3.13.4" library_version: null # e.g., "3.9.0" # Preview URLs (GCS, filled by workflow) -preview_url: null # Full PNG -preview_thumb: null # Thumbnail PNG +preview_url: null # Full PNG (responsive variants derived by convention) preview_html: null # Interactive HTML (plotly, bokeh, altair, etc.) # Quality score (0-100, filled by impl-review) diff --git a/scripts/backfill_responsive_images.py b/scripts/backfill_responsive_images.py new file mode 100644 index 0000000000..813af02e9c --- /dev/null +++ b/scripts/backfill_responsive_images.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Backfill responsive image variants for all existing plots in GCS. + +For each plot.png in production GCS, generates 7 responsive variants: + plot_1200.png, plot_1200.webp, plot_800.png, plot_800.webp, + plot_400.png, plot_400.webp, plot.webp + +Usage: + python scripts/backfill_responsive_images.py [--dry-run] [--offset N] [--limit N] [--skip-existing] +""" + +import argparse +import subprocess +import sys +import tempfile +from pathlib import Path + +# Add parent directory to path for core imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from core.images import create_responsive_variants + + +GCS_BUCKET = "gs://pyplots-images" +PRODUCTION_PREFIX = f"{GCS_BUCKET}/plots" + + +def run_cmd(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: + """Run a shell command.""" + return subprocess.run(cmd, capture_output=True, text=True, check=check) + + +def main(): + parser = argparse.ArgumentParser(description="Backfill responsive image variants in GCS") + parser.add_argument("--dry-run", action="store_true", help="List images without processing") + parser.add_argument("--offset", type=int, default=0, help="Skip first N images") + parser.add_argument("--limit", type=int, default=0, help="Process at most N images (0 = all)") + parser.add_argument("--skip-existing", action="store_true", help="Skip if plot_800.webp already exists") + args = parser.parse_args() + + # List all plot.png files in GCS production + print("Listing plot images in GCS...") + result = run_cmd(["gsutil", "ls", f"{PRODUCTION_PREFIX}/**/plot.png"]) + plot_urls = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()] + + print(f"Found {len(plot_urls)} images total") + + # Apply offset and limit + if args.offset: + plot_urls = plot_urls[args.offset:] + print(f"After offset={args.offset}: {len(plot_urls)} remaining") + if args.limit: + plot_urls = plot_urls[: args.limit] + print(f"After limit={args.limit}: {len(plot_urls)} to process") + + if args.dry_run: + print("\n[DRY RUN] Would generate responsive variants for:") + for url in plot_urls[:20]: + print(f" {url}") + if len(plot_urls) > 20: + print(f" ... and {len(plot_urls) - 20} more") + return + + success = 0 + skipped = 0 + failed = 0 + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + for i, plot_url in enumerate(plot_urls, 1): + # Extract path info: gs://pyplots-images/plots/area-basic/altair/plot.png + gcs_dir = plot_url.rsplit("/plot.png", 1)[0] + parts = plot_url.replace(f"{GCS_BUCKET}/", "").split("/") + spec_id = parts[1] + library = parts[2] + + print(f"[{i}/{len(plot_urls)}] {spec_id}/{library}...", end=" ", flush=True) + + # Optionally skip if variants already exist + if args.skip_existing: + check = run_cmd(["gsutil", "ls", f"{gcs_dir}/plot_800.webp"], check=False) + if check.returncode == 0: + print("SKIP (exists)") + skipped += 1 + continue + + try: + # Download original + local_plot = tmpdir / "plot.png" + local_outdir = tmpdir / "out" + local_outdir.mkdir(exist_ok=True) + + run_cmd(["gsutil", "cp", plot_url, str(local_plot)]) + + # Generate responsive variants + variants = create_responsive_variants(local_plot, local_outdir) + + # Upload all variants + run_cmd([ + "gsutil", "-m", "-h", "Cache-Control:public, max-age=604800", + "cp", + *[str(local_outdir / Path(v["path"]).name) for v in variants], + f"{gcs_dir}/", + ]) + + # Set public ACL + run_cmd( + ["gsutil", "-m", "acl", "ch", "-u", "AllUsers:R", f"{gcs_dir}/plot*"], + check=False, + ) + + print(f"OK ({len(variants)} variants)") + success += 1 + + # Cleanup for next iteration + for f in local_outdir.iterdir(): + f.unlink() + local_plot.unlink(missing_ok=True) + + except Exception as e: + print(f"FAILED: {e}") + failed += 1 + + print(f"\nDone! Success: {success}, Skipped: {skipped}, Failed: {failed}") + + +if __name__ == "__main__": + main() diff --git a/scripts/remove_preview_thumb_from_yaml.py b/scripts/remove_preview_thumb_from_yaml.py new file mode 100644 index 0000000000..95017b3cd9 --- /dev/null +++ b/scripts/remove_preview_thumb_from_yaml.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Remove preview_thumb field from all metadata YAML files. + +This is a one-time cleanup script. The preview_thumb field is no longer +needed because responsive image variants are derived from preview_url +by convention (issue #5191). + +Usage: + python scripts/remove_preview_thumb_from_yaml.py [--dry-run] +""" + +import re +import sys +from pathlib import Path + + +PLOTS_DIR = Path(__file__).parent.parent / "plots" +PATTERN = re.compile(r"^preview_thumb:.*\n", re.MULTILINE) + + +def main(dry_run: bool = False): + metadata_files = sorted(PLOTS_DIR.glob("*/metadata/*.yaml")) + print(f"Found {len(metadata_files)} metadata YAML files") + + modified = 0 + skipped = 0 + + for path in metadata_files: + content = path.read_text() + if "preview_thumb:" not in content: + skipped += 1 + continue + + new_content = PATTERN.sub("", content) + if new_content != content: + if dry_run: + print(f" Would update: {path.relative_to(PLOTS_DIR.parent)}") + else: + path.write_text(new_content) + modified += 1 + + action = "Would modify" if dry_run else "Modified" + print(f"\n{action}: {modified}, Skipped (no preview_thumb): {skipped}") + + +if __name__ == "__main__": + dry_run = "--dry-run" in sys.argv + main(dry_run=dry_run) diff --git a/tests/conftest.py b/tests/conftest.py index 08ccfe7d0e..f63a386f8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,6 @@ # Test constants TEST_IMAGE_URL = "https://example.com/plot.png" -TEST_THUMB_URL = "https://example.com/thumb.png" TEST_HTML_URL = "https://example.com/plot.html" @@ -124,7 +123,6 @@ async def test_db_with_data(test_session): library_id="matplotlib", code="import matplotlib.pyplot as plt\n# scatter plot code", preview_url=TEST_IMAGE_URL.replace("plot", "scatter-matplotlib"), - preview_thumb=TEST_THUMB_URL.replace("thumb", "scatter-matplotlib-thumb"), quality_score=92.5, generated_by="claude", python_version="3.13", @@ -135,7 +133,6 @@ async def test_db_with_data(test_session): library_id="seaborn", code="import seaborn as sns\n# scatter plot code", preview_url=TEST_IMAGE_URL.replace("plot", "scatter-seaborn"), - preview_thumb=TEST_THUMB_URL.replace("thumb", "scatter-seaborn-thumb"), quality_score=95.0, generated_by="claude", python_version="3.13", diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 0c6c469777..65cbf5df2d 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -27,7 +27,6 @@ # Test data constants TEST_IMAGE_URL = "https://storage.googleapis.com/pyplots-images/test/plot.png" -TEST_THUMB_URL = "https://storage.googleapis.com/pyplots-images/test/thumb.png" def _get_database_url(): @@ -181,7 +180,6 @@ async def pg_db_with_data(pg_session): library_id="matplotlib", code="import matplotlib.pyplot as plt\n# scatter plot code", preview_url=TEST_IMAGE_URL.replace("plot", "scatter-matplotlib"), - preview_thumb=TEST_THUMB_URL.replace("thumb", "scatter-matplotlib-thumb"), quality_score=92.5, generated_by="claude", python_version="3.13", @@ -192,7 +190,6 @@ async def pg_db_with_data(pg_session): library_id="seaborn", code="import seaborn as sns\n# scatter plot code", preview_url=TEST_IMAGE_URL.replace("plot", "scatter-seaborn"), - preview_thumb=TEST_THUMB_URL.replace("thumb", "scatter-seaborn-thumb"), quality_score=95.0, generated_by="claude", python_version="3.13", diff --git a/tests/unit/api/mcp/test_tools.py b/tests/unit/api/mcp/test_tools.py index 8c2e002590..ad114a701c 100644 --- a/tests/unit/api/mcp/test_tools.py +++ b/tests/unit/api/mcp/test_tools.py @@ -41,7 +41,6 @@ def mock_spec(): mock_impl.library.name = "Matplotlib" mock_impl.code = "import matplotlib.pyplot as plt" mock_impl.preview_url = "https://example.com/plot.png" - mock_impl.preview_thumb = "https://example.com/plot_thumb.png" mock_impl.preview_html = None mock_impl.quality_score = 92 mock_impl.created = None diff --git a/tests/unit/api/test_main.py b/tests/unit/api/test_main.py index 6238cbd71f..5a7a2b1e57 100644 --- a/tests/unit/api/test_main.py +++ b/tests/unit/api/test_main.py @@ -14,7 +14,7 @@ from api.main import app, fastapi_app from core.database import get_db -from tests.conftest import TEST_IMAGE_URL, TEST_THUMB_URL +from tests.conftest import TEST_IMAGE_URL @pytest.fixture @@ -30,7 +30,6 @@ def mock_db_client(): mock_impl = MagicMock() mock_impl.library_id = "matplotlib" mock_impl.preview_url = TEST_IMAGE_URL - mock_impl.preview_thumb = TEST_THUMB_URL mock_impl.preview_html = None mock_spec1 = MagicMock() diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index 56eba52c23..0f2838886b 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -18,7 +18,7 @@ _image_matches_groups, ) from core.database import get_db -from tests.conftest import TEST_IMAGE_URL, TEST_THUMB_URL +from tests.conftest import TEST_IMAGE_URL # Path to patch is_db_configured - it's now in api.dependencies @@ -72,7 +72,6 @@ def mock_spec(): mock_impl.library = MagicMock() mock_impl.library.name = "Matplotlib" mock_impl.preview_url = TEST_IMAGE_URL - mock_impl.preview_thumb = TEST_THUMB_URL mock_impl.preview_html = None mock_impl.quality_score = 92.5 mock_impl.code = "import matplotlib.pyplot as plt" @@ -874,7 +873,6 @@ def test_filter_with_limit(self, client: TestClient, mock_spec) -> None: mock_impl2 = MagicMock() mock_impl2.library_id = "seaborn" mock_impl2.preview_url = TEST_IMAGE_URL - mock_impl2.preview_thumb = TEST_THUMB_URL mock_impl2.preview_html = None mock_impl2.quality_score = 85.0 mock_impl2.impl_tags = {} @@ -901,7 +899,6 @@ def test_filter_with_offset(self, client: TestClient, mock_spec) -> None: mock_impl2 = MagicMock() mock_impl2.library_id = "seaborn" mock_impl2.preview_url = TEST_IMAGE_URL - mock_impl2.preview_thumb = TEST_THUMB_URL mock_impl2.preview_html = None mock_impl2.quality_score = 85.0 mock_impl2.impl_tags = {} @@ -927,14 +924,12 @@ def test_filter_with_limit_and_offset(self, client: TestClient, mock_spec) -> No mock_impl2 = MagicMock() mock_impl2.library_id = "seaborn" mock_impl2.preview_url = TEST_IMAGE_URL - mock_impl2.preview_thumb = TEST_THUMB_URL mock_impl2.preview_html = None mock_impl2.quality_score = 85.0 mock_impl2.impl_tags = {} mock_impl3 = MagicMock() mock_impl3.library_id = "plotly" mock_impl3.preview_url = TEST_IMAGE_URL - mock_impl3.preview_thumb = TEST_THUMB_URL mock_impl3.preview_html = None mock_impl3.quality_score = 80.0 mock_impl3.impl_tags = {} diff --git a/tests/unit/automation/scripts/test_sync_to_postgres.py b/tests/unit/automation/scripts/test_sync_to_postgres.py index 496e55297a..e667226671 100644 --- a/tests/unit/automation/scripts/test_sync_to_postgres.py +++ b/tests/unit/automation/scripts/test_sync_to_postgres.py @@ -713,7 +713,6 @@ def test_sync_specs_and_impls(self): "library_id": "matplotlib", "code": "import matplotlib", "preview_url": "https://example.com/plot.png", - "preview_thumb": None, "preview_html": None, "python_version": "3.13", "library_version": "3.10.0", @@ -813,7 +812,6 @@ def test_sync_with_extended_review_data(self): "library_id": "matplotlib", "code": "code", "preview_url": None, - "preview_thumb": None, "preview_html": None, "python_version": None, "library_version": None, @@ -867,7 +865,6 @@ def _make_plot(spec_id): "library_id": lib, "code": "code", "preview_url": None, - "preview_thumb": None, "preview_html": None, "python_version": None, "library_version": None, diff --git a/tests/unit/core/test_images.py b/tests/unit/core/test_images.py index 2a1be55dc7..84299ea0b5 100644 --- a/tests/unit/core/test_images.py +++ b/tests/unit/core/test_images.py @@ -5,7 +5,7 @@ import pytest from PIL import Image -from core.images import create_thumbnail, optimize_png, process_plot_image +from core.images import create_responsive_variants, create_thumbnail, optimize_png, process_plot_image @pytest.fixture @@ -284,6 +284,108 @@ def test_large_image(self, tmp_path: Path) -> None: assert height == 300 # Maintains 4:3 ratio +class TestCreateResponsiveVariants: + """Tests for create_responsive_variants function.""" + + def test_creates_all_variants(self, sample_image: Path, tmp_path: Path) -> None: + """Should create all 7 responsive variants.""" + output_dir = tmp_path / "variants" + results = create_responsive_variants(sample_image, output_dir, optimize=False) + + # 800px source: only 400px sized variant is smaller, so 2 formats for 400 + full webp = 3 + # Actually 800 >= 800 so 800 is skipped, 1200 >= 800 so skipped too, only 400 < 800 + expected_files = {"plot_400.png", "plot_400.webp", "plot.webp"} + actual_files = {f.name for f in output_dir.iterdir()} + assert actual_files == expected_files + assert len(results) == 3 + + def test_creates_all_sizes_for_large_image(self, tmp_path: Path) -> None: + """Should create all sized variants when source is large enough.""" + img_path = tmp_path / "large.png" + Image.new("RGB", (4000, 3000), color=(100, 150, 200)).save(img_path) + + output_dir = tmp_path / "variants" + results = create_responsive_variants(img_path, output_dir, optimize=False) + + expected_files = { + "plot_1200.png", + "plot_1200.webp", + "plot_800.png", + "plot_800.webp", + "plot_400.png", + "plot_400.webp", + "plot.webp", + } + actual_files = {f.name for f in output_dir.iterdir()} + assert actual_files == expected_files + assert len(results) == 7 + + def test_correct_dimensions(self, tmp_path: Path) -> None: + """Resized variants should have correct width and maintained aspect ratio.""" + img_path = tmp_path / "plot.png" + Image.new("RGB", (2000, 1000), color=(50, 100, 150)).save(img_path) + + output_dir = tmp_path / "variants" + create_responsive_variants(img_path, output_dir, optimize=False) + + img_800 = Image.open(output_dir / "plot_800.png") + assert img_800.width == 800 + assert img_800.height == 400 # 2:1 ratio maintained + + img_400 = Image.open(output_dir / "plot_400.png") + assert img_400.width == 400 + assert img_400.height == 200 + + def test_webp_variants_created(self, tmp_path: Path) -> None: + """WebP variants should be valid WebP files.""" + img_path = tmp_path / "plot.png" + Image.new("RGB", (2000, 1000), color=(50, 100, 150)).save(img_path) + + output_dir = tmp_path / "variants" + create_responsive_variants(img_path, output_dir, optimize=False) + + webp = Image.open(output_dir / "plot_800.webp") + assert webp.format == "WEBP" + assert webp.width == 800 + + full_webp = Image.open(output_dir / "plot.webp") + assert full_webp.format == "WEBP" + assert full_webp.width == 2000 + + def test_skips_sizes_larger_than_source(self, tmp_path: Path) -> None: + """Should not create variants larger than the source image.""" + img_path = tmp_path / "small.png" + Image.new("RGB", (500, 300), color=(100, 100, 100)).save(img_path) + + output_dir = tmp_path / "variants" + create_responsive_variants(img_path, output_dir, optimize=False) + + # 500px source: only 400 < 500, so 400 variants + full webp + filenames = {f.name for f in output_dir.iterdir()} + assert "plot_1200.png" not in filenames + assert "plot_800.png" not in filenames + assert "plot_400.png" in filenames + assert "plot.webp" in filenames + + def test_handles_rgba_input(self, tmp_path: Path) -> None: + """Should convert RGBA images to RGB.""" + img_path = tmp_path / "rgba.png" + Image.new("RGBA", (2000, 1000), color=(100, 150, 200, 128)).save(img_path) + + output_dir = tmp_path / "variants" + results = create_responsive_variants(img_path, output_dir, optimize=False) + + assert len(results) > 0 + img = Image.open(output_dir / "plot_800.png") + assert img.mode == "RGB" + + def test_creates_output_dir(self, sample_image: Path, tmp_path: Path) -> None: + """Should create output directory if it doesn't exist.""" + output_dir = tmp_path / "new" / "nested" / "dir" + create_responsive_variants(sample_image, output_dir, optimize=False) + assert output_dir.exists() + + class TestCLI: """Tests for command-line interface."""