fix(parser): merge Lora hashes over empty Hashes JSON values and skip entries without hash

This commit is contained in:
Will Miao
2026-06-26 22:31:36 +08:00
parent 09c2445ac9
commit 36ef840a22
2 changed files with 100 additions and 15 deletions

View File

@@ -123,23 +123,38 @@ class AutomaticMetadataParser(RecipeMetadataParser):
if model_hash_from_hashes: if model_hash_from_hashes:
metadata["model_hash"] = 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) lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
if not hashes_match and lora_hashes_match: if lora_hashes_match:
try: try:
lora_hashes_str = lora_hashes_match.group(1) lora_hashes_str = lora_hashes_match.group(1)
lora_hash_entries = lora_hashes_str.split(', ') 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") # Parse each lora hash entry (format: "name: hash")
for entry in lora_hash_entries: for entry in lora_hash_entries:
if ': ' in entry: if ': ' in entry:
lora_name, lora_hash = entry.split(': ', 1) lora_name, lora_hash = entry.split(': ', 1)
# Add as lora type in the same format as regular hashes lora_hash = lora_hash.strip()
metadata["hashes"][f"lora:{lora_name}"] = 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 # Remove lora hashes from params section
params_section = params_section.replace(lora_hashes_match.group(0), '') params_section = params_section.replace(lora_hashes_match.group(0), '')
@@ -363,6 +378,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
if not hash_key.startswith(("lora:", "hypernet:")): if not hash_key.startswith(("lora:", "hypernet:")):
continue 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) lora_type, lora_name = hash_key.split(':', 1)
# Get weight from extranet tags if available, else default to 1.0 # Get weight from extranet tags if available, else default to 1.0
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Try to get info from Civitai # Try to get info from Civitai
if metadata_provider: if metadata_provider:
try: try:
if lora_hash:
# If we have hash, use it for lookup
civitai_info = await metadata_provider.get_model_by_hash(lora_hash) civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
else:
civitai_info = None
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,

View File

@@ -64,6 +64,74 @@ async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeyp
assert result["loras"] == [] 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 @pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch): async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
checkpoint_info = { checkpoint_info = {