mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-26 12:51:16 -03:00
fix(parser): merge Lora hashes over empty Hashes JSON values and skip entries without hash
This commit is contained in:
@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
if model_hash_from_hashes:
|
||||
metadata["model_hash"] = model_hash_from_hashes
|
||||
|
||||
# Extract Lora hashes in alternative format
|
||||
# Extract Lora hashes in alternative format.
|
||||
# Run unconditionally (not just as fallback) so that
|
||||
# non-empty hashes from Lora hashes fill in the gaps left
|
||||
# by empty values in the Hashes JSON dict. Some WebUI
|
||||
# builds write real hash values only to Lora hashes and
|
||||
# leave the Hashes JSON values empty.
|
||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||
if not hashes_match and lora_hashes_match:
|
||||
if lora_hashes_match:
|
||||
try:
|
||||
lora_hashes_str = lora_hashes_match.group(1)
|
||||
lora_hash_entries = lora_hashes_str.split(', ')
|
||||
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
|
||||
|
||||
# Parse each lora hash entry (format: "name: hash")
|
||||
for entry in lora_hash_entries:
|
||||
if ': ' in entry:
|
||||
lora_name, lora_hash = entry.split(': ', 1)
|
||||
# Add as lora type in the same format as regular hashes
|
||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
||||
|
||||
lora_hash = lora_hash.strip()
|
||||
if not lora_hash:
|
||||
# Skip entries without a hash value
|
||||
continue
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
# Add as lora type in the same format as
|
||||
# regular hashes. Only override an
|
||||
# existing entry if its value is empty
|
||||
# (Lora hashes is the more reliable
|
||||
# source when Hashes JSON has blanks).
|
||||
key = f"lora:{lora_name}"
|
||||
existing = metadata["hashes"].get(key, "")
|
||||
if not existing:
|
||||
metadata["hashes"][key] = lora_hash
|
||||
|
||||
# Remove lora hashes from params section
|
||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||
except Exception as e:
|
||||
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Only process lora or hypernet types
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
continue
|
||||
|
||||
# Skip entries without a hash value — they can't be
|
||||
# resolved via CivitAI and would only produce a
|
||||
# useless "Deleted" entry in the recipe.
|
||||
if not lora_hash:
|
||||
continue
|
||||
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
|
||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Try to get info from Civitai
|
||||
if metadata_provider:
|
||||
try:
|
||||
if lora_hash:
|
||||
# If we have hash, use it for lookup
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
else:
|
||||
civitai_info = None
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
|
||||
@@ -64,6 +64,74 @@ async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeyp
|
||||
assert result["loras"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypatch):
|
||||
"""When Hashes JSON has empty lora hashes but Lora hashes text field has
|
||||
real ones, the real hashes should be used and those LoRAs resolved
|
||||
correctly; entries with empty hashes in both sources should be skipped."""
|
||||
lora_version_info = {
|
||||
"id": 947620,
|
||||
"modelId": 98765,
|
||||
"model": {"name": "cfg_scale_boost", "type": "LORA"},
|
||||
"name": "v1",
|
||||
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
|
||||
"baseModel": "illustrious",
|
||||
"downloadUrl": "https://civitai.com/api/download/models/947620",
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"sizeKB": 1024,
|
||||
"name": "cfg_scale_boost.safetensors",
|
||||
"hashes": {"SHA256": "4605b2de07"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def fake_metadata_provider():
|
||||
class Provider:
|
||||
async def get_model_by_hash(self, model_hash):
|
||||
assert model_hash == "4605b2de07"
|
||||
return lora_version_info, None
|
||||
|
||||
async def get_model_version_info(self, version_id):
|
||||
raise AssertionError("get_model_version_info should not be called")
|
||||
|
||||
return Provider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||
fake_metadata_provider,
|
||||
)
|
||||
|
||||
parser = AutomaticMetadataParser()
|
||||
|
||||
metadata_text = (
|
||||
"a cyberpunk portrait <lora:cfg_scale_boost:0.6>\n"
|
||||
"Negative prompt: low quality\n"
|
||||
"Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 512x768, "
|
||||
"Model hash: abc123, Model: test.safetensors, "
|
||||
'Lora hashes: "cfg_scale_boost: 4605b2de07, EmptyLora: ", '
|
||||
'Hashes: {"model": "abc123", "lora:cfg_scale_boost": "", "lora:EmptyLora": "", "lora:UnusedLora": ""}'
|
||||
)
|
||||
|
||||
result = await parser.parse_metadata(metadata_text)
|
||||
|
||||
# cfg_scale_boost should be resolved (hash from Lora hashes overrode empty Hashes JSON)
|
||||
loras = result.get("loras", [])
|
||||
assert len(loras) == 1, f"Expected 1 LoRA, got {len(loras)}"
|
||||
lora = loras[0]
|
||||
assert lora["name"] == "cfg_scale_boost", f"Expected cfg_scale_boost, got {lora['name']}"
|
||||
assert lora["hash"] == "4605b2de07", f"Expected hash 4605b2de07, got {lora['hash']}"
|
||||
assert lora.get("isDeleted") in (None, False), f"LoRA should not be deleted"
|
||||
assert lora["weight"] == 0.6, f"Expected weight 0.6, got {lora['weight']}"
|
||||
|
||||
# EmptyLora and UnusedLora should be skipped (no hash in either source)
|
||||
lora_names = [l["name"] for l in loras]
|
||||
assert "EmptyLora" not in lora_names, "EmptyLora should have been skipped"
|
||||
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
||||
checkpoint_info = {
|
||||
|
||||
Reference in New Issue
Block a user