From 720fa6d909ef55ba6a6ee9701858b5653142480f Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 7 Aug 2026 11:59:26 +0800 Subject: [PATCH] feat(recipes): expose preview width/height in recipe listing API --- py/routes/handlers/recipe_handlers.py | 9 +++ tests/routes/test_recipe_routes.py | 109 +++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 6477e69a..15de553a 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -250,6 +250,15 @@ class RecipeListingHandler: file_path = item.get("file_path") if file_path: item["file_url"] = self.format_recipe_file_url(file_path) + # Offload synchronous PIL I/O: with a cold LRU cache this + # reads up to page_size images and would block the event + # loop otherwise. Fields are omitted (not null) when the + # preview has no readable dimensions (video, missing file). + dims = await asyncio.to_thread( + ExifUtils.get_image_dimensions, file_path + ) + if dims: + item["width"], item["height"] = dims else: item.setdefault("file_url", "/loras_static/images/no-preview.png") item.setdefault("loras", []) diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 48c7a1bc..6143c215 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json from contextlib import asynccontextmanager from dataclasses import dataclass @@ -11,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional from aiohttp import FormData, web from aiohttp.test_utils import TestClient, TestServer +from PIL import Image from py.config import config from py.routes import base_recipe_routes @@ -368,6 +370,111 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N assert payload["items"][0]["loras"] == [] +async def test_list_recipes_exposes_preview_dimensions( + monkeypatch, tmp_path: Path +) -> None: + """(a) Image recipe items carry integer width/height from the on-disk file.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + recipe_path = harness.tmp_dir / "recipes" / "real.png" + recipe_path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (64, 32), color="red").save(recipe_path) + + harness.scanner.listing_items = [ + { + "id": "recipe-1", + "file_path": str(recipe_path), + "title": "Image Recipe", + "loras": [], + } + ] + harness.scanner.cached_raw = list(harness.scanner.listing_items) + + response = await harness.client.get("/api/lm/recipes") + payload = await response.json() + + assert response.status == 200 + item = payload["items"][0] + assert item["width"] == 64 + assert item["height"] == 32 + assert isinstance(item["width"], int) + assert isinstance(item["height"], int) + + +async def test_list_recipes_omits_dimensions_for_video_and_missing( + monkeypatch, tmp_path: Path +) -> None: + """(b) Video/missing-image recipes omit width/height yet still return 200.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + harness.scanner.listing_items = [ + { + "id": "recipe-video", + "file_path": str(harness.tmp_dir / "recipes" / "preview.mp4"), + "title": "Video Recipe", + "loras": [], + }, + { + "id": "recipe-missing", + "file_path": str(harness.tmp_dir / "recipes" / "gone.png"), + "title": "Missing Recipe", + "loras": [], + }, + ] + harness.scanner.cached_raw = list(harness.scanner.listing_items) + + response = await harness.client.get("/api/lm/recipes") + payload = await response.json() + + assert response.status == 200 + for item in payload["items"]: + assert "width" not in item + assert "height" not in item + + +async def test_list_recipes_offloads_dimensions_to_thread( + monkeypatch, tmp_path: Path +) -> None: + """(c) Dimension reads run through asyncio.to_thread for every item.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + recipe_path = harness.tmp_dir / "recipes" / "real.png" + recipe_path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (16, 48), color="blue").save(recipe_path) + + harness.scanner.listing_items = [ + { + "id": "recipe-1", + "file_path": str(recipe_path), + "title": "Image Recipe", + "loras": [], + }, + { + "id": "recipe-2", + "file_path": str(harness.tmp_dir / "recipes" / "gone.png"), + "title": "Missing Recipe", + "loras": [], + }, + ] + harness.scanner.cached_raw = list(harness.scanner.listing_items) + + real_to_thread = asyncio.to_thread + to_thread_calls: list[tuple] = [] + + async def counting_to_thread(fn, *args, **kwargs): + to_thread_calls.append((fn, args, kwargs)) + return await real_to_thread(fn, *args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", counting_to_thread) + + response = await harness.client.get("/api/lm/recipes") + payload = await response.json() + + assert response.status == 200 + assert len(to_thread_calls) >= len(harness.scanner.listing_items) + assert payload["items"][0]["width"] == 16 + assert payload["items"][0]["height"] == 48 + assert "width" not in payload["items"][1] + assert "height" not in payload["items"][1] + + async def test_list_recipes_passes_checkpoint_hash_filter( monkeypatch, tmp_path: Path ) -> None: @@ -909,8 +1016,6 @@ async def test_batch_import_start_missing_source(monkeypatch, tmp_path: Path) -> async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None: - import asyncio - async with recipe_harness(monkeypatch, tmp_path) as harness: original_analyze = harness.analysis.analyze_remote_image