Add ComfyMetadataParser for Civitai ComfyUI metadata handling

- Introduced ComfyMetadataParser class to parse metadata from Civitai ComfyUI JSON format.
- Implemented methods to validate metadata structure, extract LoRA and checkpoint information, and retrieve additional model details from Civitai.
- Enhanced error handling and logging for metadata parsing failures.
- Updated RecipeParserFactory to prioritize ComfyMetadataParser for valid JSON inputs.
This commit is contained in:
Will Miao
2025-03-27 15:43:58 +08:00
parent dec757c23b
commit ba18cbabfd
2 changed files with 405 additions and 0 deletions

View File

@@ -523,6 +523,250 @@ class A1111MetadataParser(RecipeMetadataParser):
return {"error": str(e), "loras": []}
class ComfyMetadataParser(RecipeMetadataParser):
"""Parser for Civitai ComfyUI metadata JSON format"""
METADATA_MARKER = r"class_type"
def is_metadata_matching(self, user_comment: str) -> bool:
"""Check if the user comment matches the ComfyUI metadata format"""
try:
data = json.loads(user_comment)
# Check if it contains class_type nodes typical of ComfyUI workflow
return isinstance(data, dict) and any(isinstance(v, dict) and 'class_type' in v for v in data.values())
except (json.JSONDecodeError, TypeError):
return False
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
"""Parse metadata from Civitai ComfyUI metadata format"""
try:
data = json.loads(user_comment)
loras = []
# Find all LoraLoader nodes
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
if not lora_nodes:
return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []}
# Process each LoraLoader node
for node_id, node in lora_nodes.items():
if 'inputs' not in node or 'lora_name' not in node['inputs']:
continue
lora_name = node['inputs'].get('lora_name', '')
# Parse the URN to extract model ID and version ID
# Format: "urn:air:sdxl:lora:civitai:1107767@1253442"
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
if not lora_id_match:
continue
model_id = lora_id_match.group(1)
model_version_id = lora_id_match.group(2)
# Get strength from node inputs
weight = node['inputs'].get('strength_model', 1.0)
# Initialize lora entry with default values
lora_entry = {
'id': model_version_id,
'modelId': model_id,
'name': f"Lora {model_id}", # Default name
'version': '',
'type': 'lora',
'weight': weight,
'existsLocally': False,
'localPath': None,
'file_name': '',
'hash': '',
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Get additional info from Civitai if client is available
if civitai_client:
try:
civitai_info = await civitai_client.get_model_version_info(model_version_id)
if civitai_info and civitai_info.get("error") != "Model not found":
# Update lora entry with model name and version
if 'model' in civitai_info and 'name' in civitai_info['model']:
lora_entry['name'] = civitai_info['model']['name']
lora_entry['version'] = civitai_info.get('name', '')
# Check if this is an early access lora
if civitai_info.get('earlyAccessEndsAt'):
early_access_date = civitai_info.get('earlyAccessEndsAt', '')
lora_entry['isEarlyAccess'] = True
lora_entry['earlyAccessEndsAt'] = early_access_date
# Get thumbnail URL from first image
if 'images' in civitai_info and civitai_info['images']:
lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
# Get base model
lora_entry['baseModel'] = civitai_info.get('baseModel', '')
# Get download URL
lora_entry['downloadUrl'] = civitai_info.get('downloadUrl', '')
# Check if this LoRA exists locally by SHA256 hash
if 'files' in civitai_info:
model_file = next((file for file in civitai_info.get('files', [])
if file.get('type') == 'Model'), None)
if model_file and recipe_scanner:
sha256 = model_file.get('hashes', {}).get('SHA256', '')
if sha256:
lora_scanner = recipe_scanner._lora_scanner
exists_locally = lora_scanner.has_lora_hash(sha256)
if exists_locally:
local_path = lora_scanner.get_lora_path_by_hash(sha256)
lora_entry['existsLocally'] = True
lora_entry['localPath'] = local_path
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]
else:
# For missing LoRAs, get file_name from model_file.name
file_name = model_file.get('name', '')
lora_entry['file_name'] = os.path.splitext(file_name)[0] if file_name else ''
lora_entry['hash'] = sha256
lora_entry['size'] = model_file.get('sizeKB', 0) * 1024
else:
lora_entry['isDeleted'] = True
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA: {e}")
loras.append(lora_entry)
# Find checkpoint info
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
checkpoint = None
checkpoint_id = None
checkpoint_version_id = None
if checkpoint_nodes:
# Get the first checkpoint node
checkpoint_node = next(iter(checkpoint_nodes.values()))
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
# Parse checkpoint URN
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
if checkpoint_match:
checkpoint_id = checkpoint_match.group(1)
checkpoint_version_id = checkpoint_match.group(2)
checkpoint = {
'id': checkpoint_version_id,
'modelId': checkpoint_id,
'name': f"Checkpoint {checkpoint_id}",
'version': '',
'type': 'checkpoint'
}
# Get additional checkpoint info from Civitai
if civitai_client:
try:
civitai_info = await civitai_client.get_model_version_info(checkpoint_version_id)
if civitai_info and civitai_info.get("error") != "Model not found":
if 'model' in civitai_info and 'name' in civitai_info['model']:
checkpoint['name'] = civitai_info['model']['name']
checkpoint['version'] = civitai_info.get('name', '')
except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
# Extract generation parameters
gen_params = {}
# First try to get from extraMetadata
if 'extraMetadata' in data:
try:
# extraMetadata is a JSON string that needs to be parsed
extra_metadata = json.loads(data['extraMetadata'])
# Map fields from extraMetadata to our standard format
mapping = {
'prompt': 'prompt',
'negativePrompt': 'negative_prompt',
'steps': 'steps',
'sampler': 'sampler',
'cfgScale': 'cfg_scale',
'seed': 'seed'
}
for src_key, dest_key in mapping.items():
if src_key in extra_metadata:
gen_params[dest_key] = extra_metadata[src_key]
# If size info is available, format as "width x height"
if 'width' in extra_metadata and 'height' in extra_metadata:
gen_params['size'] = f"{extra_metadata['width']}x{extra_metadata['height']}"
except Exception as e:
logger.error(f"Error parsing extraMetadata: {e}")
# If extraMetadata doesn't have all the info, try to get from nodes
if not gen_params or len(gen_params) < 3: # At least we want prompt, negative_prompt, and steps
# Find positive prompt node
positive_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and
v.get('class_type', '').endswith('CLIPTextEncode') and
v.get('_meta', {}).get('title') == 'Positive'}
if positive_nodes:
positive_node = next(iter(positive_nodes.values()))
if 'inputs' in positive_node and 'text' in positive_node['inputs']:
gen_params['prompt'] = positive_node['inputs']['text']
# Find negative prompt node
negative_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and
v.get('class_type', '').endswith('CLIPTextEncode') and
v.get('_meta', {}).get('title') == 'Negative'}
if negative_nodes:
negative_node = next(iter(negative_nodes.values()))
if 'inputs' in negative_node and 'text' in negative_node['inputs']:
gen_params['negative_prompt'] = negative_node['inputs']['text']
# Find KSampler node for other parameters
ksampler_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'KSampler'}
if ksampler_nodes:
ksampler_node = next(iter(ksampler_nodes.values()))
if 'inputs' in ksampler_node:
inputs = ksampler_node['inputs']
if 'sampler_name' in inputs:
gen_params['sampler'] = inputs['sampler_name']
if 'steps' in inputs:
gen_params['steps'] = inputs['steps']
if 'cfg' in inputs:
gen_params['cfg_scale'] = inputs['cfg']
if 'seed' in inputs:
gen_params['seed'] = inputs['seed']
# Determine base model from loras info
base_model = None
if loras:
# Use the most common base model from loras
base_models = [lora['baseModel'] for lora in loras if lora.get('baseModel')]
if base_models:
from collections import Counter
base_model_counts = Counter(base_models)
base_model = base_model_counts.most_common(1)[0][0]
return {
'base_model': base_model,
'loras': loras,
'checkpoint': checkpoint,
'gen_params': gen_params,
'from_comfy_metadata': True
}
except Exception as e:
logger.error(f"Error parsing ComfyUI metadata: {e}", exc_info=True)
return {"error": str(e), "loras": []}
class RecipeParserFactory:
"""Factory for creating recipe metadata parsers"""
@@ -537,6 +781,14 @@ class RecipeParserFactory:
Returns:
Appropriate RecipeMetadataParser implementation
"""
# Try ComfyMetadataParser first since it requires valid JSON
try:
if ComfyMetadataParser().is_metadata_matching(user_comment):
return ComfyMetadataParser()
except Exception:
# If JSON parsing fails, move on to other parsers
pass
if RecipeFormatParser().is_metadata_matching(user_comment):
return RecipeFormatParser()
elif StandardMetadataParser().is_metadata_matching(user_comment):

View File

@@ -0,0 +1,153 @@
{
"resource-stack": {
"class_type": "CheckpointLoaderSimple",
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
},
"resource-stack-1": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
"strength_model": 1,
"strength_clip": 1,
"model": ["resource-stack", 0],
"clip": ["resource-stack", 1]
}
},
"resource-stack-2": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
"strength_model": 1,
"strength_clip": 1,
"model": ["resource-stack-1", 0],
"clip": ["resource-stack-1", 1]
}
},
"resource-stack-3": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
"strength_model": 1.55,
"strength_clip": 1,
"model": ["resource-stack-2", 0],
"clip": ["resource-stack-2", 1]
}
},
"6": {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
"parser": "comfy",
"text_g": "",
"text_l": "",
"ascore": 2.5,
"width": 0,
"height": 0,
"crop_w": 0,
"crop_h": 0,
"target_width": 0,
"target_height": 0,
"smZ_steps": 1,
"mean_normalization": true,
"multi_conditioning": true,
"use_old_emphasis_implementation": false,
"with_SDXL": false,
"clip": ["resource-stack-3", 1]
},
"_meta": { "title": "Positive" }
},
"7": {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"text": "bad quality,worst quality,worst detail,sketch,censor",
"parser": "comfy",
"text_g": "",
"text_l": "",
"ascore": 2.5,
"width": 0,
"height": 0,
"crop_w": 0,
"crop_h": 0,
"target_width": 0,
"target_height": 0,
"smZ_steps": 1,
"mean_normalization": true,
"multi_conditioning": true,
"use_old_emphasis_implementation": false,
"with_SDXL": false,
"clip": ["resource-stack-3", 1]
},
"_meta": { "title": "Negative" }
},
"20": {
"class_type": "UpscaleModelLoader",
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
"_meta": { "title": "Load Upscale Model" }
},
"17": {
"class_type": "LoadImage",
"inputs": {
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
"upload": "image"
},
"_meta": { "title": "Image Load" }
},
"19": {
"class_type": "ImageUpscaleWithModel",
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
"_meta": { "title": "Upscale Image (using Model)" }
},
"23": {
"class_type": "ImageScale",
"inputs": {
"upscale_method": "nearest-exact",
"crop": "disabled",
"width": 1280,
"height": 1856,
"image": ["19", 0]
},
"_meta": { "title": "Upscale Image" }
},
"21": {
"class_type": "VAEEncode",
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
"_meta": { "title": "VAE Encode" }
},
"11": {
"class_type": "KSampler",
"inputs": {
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"seed": 2088370631,
"steps": 47,
"cfg": 6.5,
"denoise": 0.3,
"model": ["resource-stack-3", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["21", 0]
},
"_meta": { "title": "KSampler" }
},
"13": {
"class_type": "VAEDecode",
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
"_meta": { "title": "VAE Decode" }
},
"12": {
"class_type": "SaveImage",
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
"_meta": { "title": "Save Image" }
},
"extra": {
"airs": [
"urn:air:other:upscaler:civitai:147759@164821",
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
"urn:air:sdxl:lora:civitai:1107767@1253442",
"urn:air:sdxl:lora:civitai:1342708@1516344",
"urn:air:sdxl:lora:civitai:122359@135867"
]
},
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
}