mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-09 07:20:15 -03:00
feat(recipes): pass local hash cache to remote and url recipe imports
This commit is contained in:
@@ -1118,6 +1118,12 @@ class RecipeManagementHandler:
|
|||||||
_original_image_url,
|
_original_image_url,
|
||||||
) = await self._download_remote_media(image_url)
|
) = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# Build a version-cached map of local model hashes to cache items so
|
||||||
|
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||||
|
# exist on disk. Built once and shared by every parse pass below.
|
||||||
|
local_cache = await recipe_scanner.build_local_hash_cache()
|
||||||
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||||
|
|
||||||
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
||||||
embedded_gen_params = {}
|
embedded_gen_params = {}
|
||||||
parsed_embedded = None
|
parsed_embedded = None
|
||||||
@@ -1139,9 +1145,16 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
parsed_embedded = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
raw_embedded, recipe_scanner=recipe_scanner
|
parsed_embedded = await parser.parse_metadata(
|
||||||
)
|
raw_embedded,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
embedded_gen_params = parsed_embedded["gen_params"]
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
else:
|
else:
|
||||||
@@ -1172,9 +1185,16 @@ class RecipeManagementHandler:
|
|||||||
civitai_inner_meta
|
civitai_inner_meta
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
civitai_parsed = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
civitai_inner_meta, recipe_scanner=recipe_scanner
|
civitai_parsed = await parser.parse_metadata(
|
||||||
)
|
civitai_inner_meta,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if civitai_parsed and "gen_params" in civitai_parsed:
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
# Merge: API gen_params override EXIF at field level,
|
# Merge: API gen_params override EXIF at field level,
|
||||||
# EXIF fills in fields the API doesn't have.
|
# EXIF fills in fields the API doesn't have.
|
||||||
@@ -1798,6 +1818,12 @@ class RecipeManagementHandler:
|
|||||||
await self._download_remote_media(image_url)
|
await self._download_remote_media(image_url)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build a version-cached map of local model hashes to cache items so
|
||||||
|
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||||
|
# exist on disk. Built once and shared by every parse pass below.
|
||||||
|
local_cache = await recipe_scanner.build_local_hash_cache()
|
||||||
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||||
|
|
||||||
# Extract embedded EXIF metadata
|
# Extract embedded EXIF metadata
|
||||||
embedded_gen_params = {}
|
embedded_gen_params = {}
|
||||||
parsed_embedded = None
|
parsed_embedded = None
|
||||||
@@ -1819,9 +1845,16 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
parsed_embedded = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
raw_embedded, recipe_scanner=recipe_scanner
|
parsed_embedded = await parser.parse_metadata(
|
||||||
)
|
raw_embedded,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
embedded_gen_params = parsed_embedded["gen_params"]
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
finally:
|
finally:
|
||||||
@@ -1859,9 +1892,16 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
parsed_embedded = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
raw_orig, recipe_scanner=recipe_scanner
|
parsed_embedded = await parser.parse_metadata(
|
||||||
)
|
raw_orig,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_orig, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
parsed_embedded
|
parsed_embedded
|
||||||
and "gen_params" in parsed_embedded
|
and "gen_params" in parsed_embedded
|
||||||
@@ -1895,9 +1935,16 @@ class RecipeManagementHandler:
|
|||||||
civitai_inner_meta
|
civitai_inner_meta
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
civitai_parsed = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
civitai_inner_meta, recipe_scanner=recipe_scanner
|
civitai_parsed = await parser.parse_metadata(
|
||||||
)
|
civitai_inner_meta,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if civitai_parsed and "gen_params" in civitai_parsed:
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
# Merge: API gen_params override EXIF at field level,
|
# Merge: API gen_params override EXIF at field level,
|
||||||
# EXIF fills in fields the API doesn't have.
|
# EXIF fills in fields the API doesn't have.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from py.config import config
|
|||||||
from py.routes import base_recipe_routes
|
from py.routes import base_recipe_routes
|
||||||
from py.routes.handlers import recipe_handlers
|
from py.routes.handlers import recipe_handlers
|
||||||
from py.routes.recipe_routes import RecipeRoutes
|
from py.routes.recipe_routes import RecipeRoutes
|
||||||
|
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||||
from py.services.recipes import RecipeValidationError, RecipeNotFoundError
|
from py.services.recipes import RecipeValidationError, RecipeNotFoundError
|
||||||
from py.services.service_registry import ServiceRegistry
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ class StubRecipeScanner:
|
|||||||
self.lora_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
self.lora_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
self.image_id_map_override: Dict[str, str] = {}
|
self.image_id_map_override: Dict[str, str] = {}
|
||||||
|
self.local_hash_cache: Dict[str, Dict[str, Any]] | None = None
|
||||||
|
|
||||||
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
|
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
|
||||||
return None
|
return None
|
||||||
@@ -64,6 +66,11 @@ class StubRecipeScanner:
|
|||||||
image_id_map=dict(getattr(self, "image_id_map_override", {})),
|
image_id_map=dict(getattr(self, "image_id_map_override", {})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def build_local_hash_cache(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Return the stub's local hash map; empty unless a test overrides it."""
|
||||||
|
cache = getattr(self, "local_hash_cache", None)
|
||||||
|
return cache if cache is not None else {}
|
||||||
|
|
||||||
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]:
|
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]:
|
||||||
self.last_paginated_params = params
|
self.last_paginated_params = params
|
||||||
items = [dict(item) for item in self.listing_items]
|
items = [dict(item) for item in self.listing_items]
|
||||||
@@ -170,6 +177,8 @@ class StubPersistenceService:
|
|||||||
tags,
|
tags,
|
||||||
metadata,
|
metadata,
|
||||||
extension=None,
|
extension=None,
|
||||||
|
recipe_id=None,
|
||||||
|
target_dir=None,
|
||||||
) -> SimpleNamespace: # noqa: D401
|
) -> SimpleNamespace: # noqa: D401
|
||||||
self.save_calls.append(
|
self.save_calls.append(
|
||||||
{
|
{
|
||||||
@@ -180,6 +189,8 @@ class StubPersistenceService:
|
|||||||
"tags": list(tags),
|
"tags": list(tags),
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"extension": extension,
|
"extension": extension,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"target_dir": target_dir,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.save_result
|
return self.save_result
|
||||||
@@ -1258,3 +1269,190 @@ async def test_import_from_url_proceeds_when_image_id_not_in_map(
|
|||||||
# but it must NOT return already_exists
|
# but it must NOT return already_exists
|
||||||
payload = await response.json()
|
payload = await response.json()
|
||||||
assert payload.get("already_exists") is not True
|
assert payload.get("already_exists") is not True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_remote_recipe_passes_local_cache_to_civitai_parser(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""import_remote must hand the same local hash cache to both Civitai parse passes."""
|
||||||
|
parse_calls: list[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
class SpyCivitaiParser(CivitaiApiMetadataParser):
|
||||||
|
async def parse_metadata(
|
||||||
|
self, user_comment, recipe_scanner=None, civitai_client=None,
|
||||||
|
local_cache=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
parse_calls.append({"local_cache": local_cache})
|
||||||
|
return {"gen_params": {"prompt": "spy"}}
|
||||||
|
|
||||||
|
class SpyFactory:
|
||||||
|
def create_parser(self, raw):
|
||||||
|
return SpyCivitaiParser()
|
||||||
|
|
||||||
|
class MockExifUtils:
|
||||||
|
@staticmethod
|
||||||
|
def extract_image_metadata(path):
|
||||||
|
return "Recipe metadata: " + json.dumps({"gen_params": {"seed": 7}})
|
||||||
|
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return SimpleNamespace(get_model_version_info=lambda _id: ({}, None))
|
||||||
|
|
||||||
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.enrichment.get_default_metadata_provider",
|
||||||
|
fake_get_default_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.analysis._recipe_parser_factory = SpyFactory()
|
||||||
|
cache_marker = {"abcdef123456": {"model_name": "local-lora"}}
|
||||||
|
harness.scanner.local_hash_cache = cache_marker
|
||||||
|
harness.civitai.image_info["1"] = {
|
||||||
|
"id": 1,
|
||||||
|
"url": "https://example.com/images/1.jpg",
|
||||||
|
"meta": {"prompt": "from civitai", "seed": 99},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/import-remote",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.com/images/1",
|
||||||
|
"name": "Civitai Cache",
|
||||||
|
"resources": json.dumps([]),
|
||||||
|
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
# Both parse passes (EXIF + CivitAI API) ran and received the SAME
|
||||||
|
# local_cache object, built once at handler level.
|
||||||
|
assert len(parse_calls) == 2
|
||||||
|
assert all(call["local_cache"] is cache_marker for call in parse_calls)
|
||||||
|
assert parse_calls[0]["local_cache"] is parse_calls[1]["local_cache"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_remote_recipe_does_not_pass_local_cache_to_other_parsers(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Non-Civitai parsers must never receive local_cache (their signature lacks it)."""
|
||||||
|
parse_calls: list[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
class PlainParser:
|
||||||
|
async def parse_metadata(self, raw, recipe_scanner=None, **kwargs):
|
||||||
|
assert "local_cache" not in kwargs, (
|
||||||
|
"local_cache leaked to a non-Civitai parser"
|
||||||
|
)
|
||||||
|
parse_calls.append({"raw": raw})
|
||||||
|
return {"gen_params": {"prompt": "plain"}}
|
||||||
|
|
||||||
|
class PlainFactory:
|
||||||
|
def create_parser(self, raw):
|
||||||
|
return PlainParser()
|
||||||
|
|
||||||
|
class MockExifUtils:
|
||||||
|
@staticmethod
|
||||||
|
def extract_image_metadata(path):
|
||||||
|
return "Recipe metadata: " + json.dumps({"gen_params": {"seed": 7}})
|
||||||
|
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return SimpleNamespace(get_model_version_info=lambda _id: ({}, None))
|
||||||
|
|
||||||
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.enrichment.get_default_metadata_provider",
|
||||||
|
fake_get_default_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.analysis._recipe_parser_factory = PlainFactory()
|
||||||
|
harness.scanner.local_hash_cache = {"deadbeef": {"model_name": "local"}}
|
||||||
|
harness.civitai.image_info["1"] = {
|
||||||
|
"id": 1,
|
||||||
|
"url": "https://example.com/images/1.jpg",
|
||||||
|
"meta": {"prompt": "from civitai", "seed": 99},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/import-remote",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.com/images/1",
|
||||||
|
"name": "Plain Cache",
|
||||||
|
"resources": json.dumps([]),
|
||||||
|
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert len(parse_calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_from_url_passes_local_cache_on_all_three_parse_calls(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""import_from_url must pass local_cache to all three Civitai parse passes."""
|
||||||
|
parse_calls: list[Dict[str, Any]] = []
|
||||||
|
str_call_count = 0
|
||||||
|
|
||||||
|
class SpyCivitaiParser(CivitaiApiMetadataParser):
|
||||||
|
async def parse_metadata(
|
||||||
|
self, user_comment, recipe_scanner=None, civitai_client=None,
|
||||||
|
local_cache=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
nonlocal str_call_count
|
||||||
|
parse_calls.append({"local_cache": local_cache})
|
||||||
|
if isinstance(user_comment, str):
|
||||||
|
str_call_count += 1
|
||||||
|
if str_call_count == 1:
|
||||||
|
# First (optimized EXIF) pass yields an empty dict so the
|
||||||
|
# handler falls back to the original image, exercising
|
||||||
|
# pass #2 (an empty dict is falsy for `not parsed_embedded`).
|
||||||
|
return {}
|
||||||
|
return {"gen_params": {"prompt": "fallback"}}
|
||||||
|
return {"gen_params": {"prompt": "civitai"}}
|
||||||
|
|
||||||
|
class SpyFactory:
|
||||||
|
def create_parser(self, raw):
|
||||||
|
return SpyCivitaiParser()
|
||||||
|
|
||||||
|
class MockExifUtils:
|
||||||
|
@staticmethod
|
||||||
|
def extract_image_metadata(path):
|
||||||
|
if path.endswith(".png"):
|
||||||
|
return "original metadata: " + json.dumps({"gen_params": {"seed": 2}})
|
||||||
|
return "optimized metadata: " + json.dumps({"gen_params": {"seed": 1}})
|
||||||
|
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return SimpleNamespace(get_model_version_info=lambda _id: ({}, None))
|
||||||
|
|
||||||
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.enrichment.get_default_metadata_provider",
|
||||||
|
fake_get_default_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.analysis._recipe_parser_factory = SpyFactory()
|
||||||
|
cache_marker = {"deadbeef": {"model_name": "local-lora"}}
|
||||||
|
harness.scanner.local_hash_cache = cache_marker
|
||||||
|
harness.civitai.image_info["42"] = {
|
||||||
|
"id": 42,
|
||||||
|
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
|
||||||
|
"type": "image",
|
||||||
|
"meta": {"prompt": "test", "seed": 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/import-from-url",
|
||||||
|
params={"image_url": "https://civitai.com/images/42"},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert len(parse_calls) == 3
|
||||||
|
assert all(call["local_cache"] is cache_marker for call in parse_calls)
|
||||||
|
assert parse_calls[0]["local_cache"] is parse_calls[2]["local_cache"]
|
||||||
|
|||||||
Reference in New Issue
Block a user