mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-29 17:01:26 -03:00
fix(recipes): resolve stale LoRA hash on import and add hashInvalid state
- import: prefer A1111 Lora hashes (12-char AutoV3) over conflicting Hashes JSON values; recover the quote-wrapped AutoV3 from CivitAI image API meta; merge EXIF-parsed LoRAs when the API-only parse yields none (meta=null) - rematch: treat entries whose hash failed CivitAI resolution (hashInvalid) as unresolved candidates; clear the flag on rematch/reconnect write-back - download: persist hashInvalid and show a distinct toast when hash lookup returns "Model not found", so unresolvable entries become recoverable - ui: add Unresolvable Hash badge styling and reconnect affordance - i18n: translate the new keys across all 10 locales
This commit is contained in:
@@ -1313,3 +1313,183 @@ async def test_reconnect_lora_distinguishes_ambiguous_mismatched_and_missing(tmp
|
||||
await service.reconnect_lora(
|
||||
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="missing"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_lora_hash_invalid_delegates_and_reports(tmp_path):
|
||||
service = RecipePersistenceService(
|
||||
exif_utils=DummyExifUtils(),
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
async def set_lora_entry_hash_invalid(self, recipe_id, lora_index, hash_invalid):
|
||||
assert recipe_id == "r1"
|
||||
assert lora_index == 0
|
||||
assert hash_invalid is True
|
||||
return (
|
||||
{"id": "r1", "loras": [{"file_name": "m", "hashInvalid": True}]},
|
||||
{"file_name": "m", "hashInvalid": True},
|
||||
)
|
||||
|
||||
result = await service.mark_lora_hash_invalid(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1", lora_index=0
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["recipe_id"] == "r1"
|
||||
assert result.payload["hash_invalid"] is True
|
||||
assert result.payload["updated_lora"]["hashInvalid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_lora_hash_invalid_can_clear_flag(tmp_path):
|
||||
service = RecipePersistenceService(
|
||||
exif_utils=DummyExifUtils(),
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
async def set_lora_entry_hash_invalid(self, recipe_id, lora_index, hash_invalid):
|
||||
assert hash_invalid is False
|
||||
return (
|
||||
{"id": "r1", "loras": [{"file_name": "m", "hashInvalid": False}]},
|
||||
{"file_name": "m", "hashInvalid": False},
|
||||
)
|
||||
|
||||
result = await service.mark_lora_hash_invalid(
|
||||
recipe_scanner=DummyScanner(),
|
||||
recipe_id="r1",
|
||||
lora_index=0,
|
||||
hash_invalid=False,
|
||||
)
|
||||
|
||||
assert result.payload["hash_invalid"] is False
|
||||
assert result.payload["updated_lora"]["hashInvalid"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_remote_image_meta_null_keeps_exif_loras(tmp_path, monkeypatch):
|
||||
"""When the CivitAI image API meta is null (only modelVersionIds
|
||||
present), the EXIF-parsed LoRAs must be merged into the result — they
|
||||
were previously dropped because the API-only parse yields a checkpoint
|
||||
but no LoRAs."""
|
||||
A1111_METADATA = (
|
||||
"woman, natural blonde hair, ice blue eyes, <lora:Daphne Blake Cosplay_v1:1> daphne blake cosplay, upper body\n"
|
||||
"Negative prompt: low quality\n"
|
||||
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 4140408634, "
|
||||
"Size: 512x768, Model hash: 3c8530cb22, Model: cyberrealistic_v33, "
|
||||
'Lora hashes: "Daphne Blake Cosplay_v1: e67ebd5e315f", '
|
||||
'Hashes: {"lora:Daphne Blake Cosplay_v1": "a2a12bfa01"}'
|
||||
)
|
||||
LORA_SHA256 = "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276"
|
||||
|
||||
class FakeExif:
|
||||
def extract_image_metadata(self, path):
|
||||
return A1111_METADATA
|
||||
|
||||
class FakeDownloader:
|
||||
async def download_file(self, url, path, use_auth=False):
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(b"fake-image")
|
||||
return True, None
|
||||
|
||||
async def downloader_factory():
|
||||
return FakeDownloader()
|
||||
|
||||
class FakeCivitaiClient:
|
||||
async def get_image_info(self, image_id, source_url=None):
|
||||
return {
|
||||
"id": 7076441,
|
||||
"url": "https://image.civitai.com/x/original=true/x.jpeg",
|
||||
"type": "image",
|
||||
"meta": None,
|
||||
"modelVersionIds": [138176],
|
||||
"browsingLevel": 1,
|
||||
}
|
||||
|
||||
async def fake_metadata_provider():
|
||||
class Provider:
|
||||
async def get_model_version_info(self, version_id):
|
||||
if version_id == "138176":
|
||||
return {
|
||||
"id": 138176,
|
||||
"modelId": 15003,
|
||||
"model": {"name": "CyberRealistic", "type": "checkpoint"},
|
||||
"name": "v3.3",
|
||||
"baseModel": "SD 1.5",
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"name": "cyberrealistic_v33.safetensors",
|
||||
"hashes": {"SHA256": "3c8530cb2239b686d23a94627e29883fe44a1605f31a777727b6709f80d11679"},
|
||||
}
|
||||
],
|
||||
}, None
|
||||
return None, "Model not found"
|
||||
|
||||
async def get_model_by_hash(self, model_hash):
|
||||
if model_hash == "e67ebd5e315f":
|
||||
return {
|
||||
"id": 359072,
|
||||
"modelId": 320224,
|
||||
"model": {"name": "Daphne Blake Cosplay (Scooby Doo)", "type": "lora"},
|
||||
"name": "v1.0",
|
||||
"baseModel": "SD 1.5",
|
||||
"downloadUrl": "https://civitai.com/api/download/359072",
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"name": "Daphne Blake Cosplay_v1.safetensors",
|
||||
"hashes": {"SHA256": LORA_SHA256.upper()},
|
||||
}
|
||||
],
|
||||
}, None
|
||||
return None, "Model not found"
|
||||
|
||||
return Provider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||
fake_metadata_provider,
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
async def build_local_hash_cache(self):
|
||||
return {}
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fp):
|
||||
return []
|
||||
|
||||
async def get_local_lora(self, name, base_model=None):
|
||||
return None
|
||||
|
||||
async def get_local_lora_by_hash(self, hash_value):
|
||||
return None
|
||||
|
||||
from py.recipes.factory import RecipeParserFactory
|
||||
|
||||
service = RecipeAnalysisService(
|
||||
exif_utils=FakeExif(),
|
||||
recipe_parser_factory=RecipeParserFactory(),
|
||||
downloader_factory=downloader_factory,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
result = await service.analyze_remote_image(
|
||||
url="https://civitai.red/images/7076441",
|
||||
recipe_scanner=DummyScanner(),
|
||||
civitai_client=FakeCivitaiClient(),
|
||||
)
|
||||
payload = result.payload
|
||||
|
||||
assert payload.get("error") is None
|
||||
loras = payload.get("loras") or []
|
||||
assert len(loras) == 1
|
||||
assert loras[0]["hash"] == LORA_SHA256
|
||||
assert loras[0].get("isDeleted") in (None, False)
|
||||
assert "Daphne" in str(payload.get("gen_params", {}).get("prompt"))
|
||||
|
||||
Reference in New Issue
Block a user