mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-08 15:00: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
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -44,6 +44,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
|||||||
RecipeScannerGetter = Callable[[], Any]
|
RecipeScannerGetter = Callable[[], Any]
|
||||||
CivitaiClientGetter = 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)
|
@dataclass(frozen=True)
|
||||||
class RecipeHandlerSet:
|
class RecipeHandlerSet:
|
||||||
@@ -246,24 +262,36 @@ class RecipeListingHandler:
|
|||||||
recursive=recursive,
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in result.get("items", []):
|
items = result.get("items", [])
|
||||||
|
for item in items:
|
||||||
file_path = item.get("file_path")
|
file_path = item.get("file_path")
|
||||||
if file_path:
|
if file_path:
|
||||||
item["file_url"] = self.format_recipe_file_url(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:
|
else:
|
||||||
item.setdefault("file_url", "/loras_static/images/no-preview.png")
|
item.setdefault("file_url", "/loras_static/images/no-preview.png")
|
||||||
item.setdefault("loras", [])
|
item.setdefault("loras", [])
|
||||||
item.setdefault("base_model", "")
|
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)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
|
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]
|
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(
|
async def test_list_recipes_passes_checkpoint_hash_filter(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user