mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-26 21:01:16 -03:00
fix(parser): merge Lora hashes over empty Hashes JSON values and skip entries without hash
This commit is contained in:
@@ -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:
|
civitai_info = await metadata_provider.get_model_by_hash(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
|
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user