mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 06:20:15 -03:00
perf(recipes): batch preview dimension reads via asyncio.gather
This commit is contained in:
@@ -10,7 +10,7 @@ import asyncio
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -44,6 +44,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||
RecipeScannerGetter = Callable[[], Any]
|
||||
CivitaiClientGetter = Callable[[], Any]
|
||||
|
||||
# Cap concurrent preview-dimension reads across requests. With a cold LRU
|
||||
# cache one page can touch up to page_size image files; 16 balances SSD and
|
||||
# HDD throughput without starving the event loop.
|
||||
_DIMS_READ_SEMAPHORE = asyncio.Semaphore(16)
|
||||
|
||||
|
||||
async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
|
||||
"""Read preview dimensions off the event loop under the concurrency cap.
|
||||
|
||||
PIL I/O runs in a worker thread so it never blocks the event loop, and the
|
||||
semaphore bounds how many files are opened at once even when many list
|
||||
requests land together.
|
||||
"""
|
||||
async with _DIMS_READ_SEMAPHORE:
|
||||
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecipeHandlerSet:
|
||||
@@ -246,24 +262,36 @@ class RecipeListingHandler:
|
||||
recursive=recursive,
|
||||
)
|
||||
|
||||
for item in result.get("items", []):
|
||||
items = result.get("items", [])
|
||||
for item in items:
|
||||
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", [])
|
||||
item.setdefault("base_model", "")
|
||||
|
||||
# Batch preview dimension reads with asyncio.gather. The previous
|
||||
# loop awaited asyncio.to_thread once per item, so a page_size=100
|
||||
# request submitted 100 sequential thread calls (50-300ms cold-page
|
||||
# latency). gather runs them concurrently while the semaphore caps
|
||||
# disk opens; dimensions stay omitted (not null) when a preview has
|
||||
# no readable size (video, missing file).
|
||||
to_read = [
|
||||
(i, item.get("file_path"))
|
||||
for i, item in enumerate(items)
|
||||
if item.get("file_path")
|
||||
]
|
||||
if to_read:
|
||||
dims_list = await asyncio.gather(
|
||||
*(_read_preview_dims(path) for _, path in to_read)
|
||||
)
|
||||
for (idx, _), dims in zip(to_read, dims_list):
|
||||
if dims:
|
||||
item = items[idx]
|
||||
item["width"], item["height"] = dims
|
||||
|
||||
return web.json_response(result)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
|
||||
|
||||
@@ -475,6 +475,58 @@ async def test_list_recipes_offloads_dimensions_to_thread(
|
||||
assert "height" not in payload["items"][1]
|
||||
|
||||
|
||||
async def test_list_recipes_batches_dimensions_for_mixed_items(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""(d) Mixed file_path presence: dims align per-item with their own files."""
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
wide_path = harness.tmp_dir / "recipes" / "wide.png"
|
||||
tall_path = harness.tmp_dir / "recipes" / "tall.png"
|
||||
wide_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (120, 40), color="green").save(wide_path)
|
||||
Image.new("RGB", (30, 90), color="blue").save(tall_path)
|
||||
|
||||
harness.scanner.listing_items = [
|
||||
{
|
||||
"id": "recipe-wide",
|
||||
"file_path": str(wide_path),
|
||||
"title": "Wide",
|
||||
"loras": [],
|
||||
},
|
||||
{"id": "recipe-none", "title": "No Preview", "loras": []},
|
||||
{
|
||||
"id": "recipe-tall",
|
||||
"file_path": str(tall_path),
|
||||
"title": "Tall",
|
||||
"loras": [],
|
||||
},
|
||||
{"id": "recipe-none-2", "title": "No Preview 2", "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
|
||||
items = payload["items"]
|
||||
|
||||
# Items with a file_path carry integer dims read from their own file;
|
||||
# the two images differ in both dimensions so a shifted pair would
|
||||
# fail these assertions.
|
||||
assert items[0]["width"] == 120
|
||||
assert items[0]["height"] == 40
|
||||
assert isinstance(items[0]["width"], int)
|
||||
assert isinstance(items[0]["height"], int)
|
||||
assert items[2]["width"] == 30
|
||||
assert items[2]["height"] == 90
|
||||
|
||||
# Items without a file_path get the no-preview fallback and omit dims.
|
||||
for item in (items[1], items[3]):
|
||||
assert "width" not in item
|
||||
assert "height" not in item
|
||||
assert item["file_url"] == "/loras_static/images/no-preview.png"
|
||||
|
||||
|
||||
async def test_list_recipes_passes_checkpoint_hash_filter(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user