Files
MatthewClayHarrison-ComfyUI…/metaman.py
T
PXL8D 7ddc19c1b4 Session 8 Priority 1: Simplify LoRA output format to real_name and weight only
- Modified MetaManExtractComponents to return simplified LoRA list
- Enhanced LoRAs: Only real_name and weight fields
- Regular LoRAs: name mapped to real_name, weight preserved
- Removes unnecessary hash, label, and other metadata fields
- Cleaner output for workflow integration
- Updated logging to show simplified LoRA counts
2025-08-13 11:44:36 -07:00

2752 lines
126 KiB
Python

"""
MetaMan Node for ComfyUI
Universal metadata management for AI image generation platforms
Supports A1111, ComfyUI, Civitai, Tensor.ai, Forge, and other services
"""
import torch
import json
import yaml
import os
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import piexif
import folder_paths
import hashlib
import re
from typing import Optional, Any, Union
from datetime import datetime
class MetaManLoadAndConvert:
"""
Load image file, extract metadata, and convert to target service format
"""
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
return {
"required": {
"image_file": (sorted(files), {"image_upload": True}),
"target_service": (["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "leonardo.ai"], {"default": "automatic1111"})
}
}
CATEGORY = "MetaMan"
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "converted_metadata")
FUNCTION = "load_and_convert"
DESCRIPTION = "Load image with metadata and convert to target service format"
def load_and_convert(self, image_file, target_service):
"""
Load image, extract metadata, and convert to target format
"""
try:
input_dir = folder_paths.get_input_directory()
image_path = folder_paths.get_annotated_filepath(image_file, input_dir)
print(f"MetaMan Load & Convert: Loading {image_path}")
# Load image with PIL to preserve metadata
pil_image = Image.open(image_path)
# Extract metadata from the original file
source_metadata = self._extract_metadata_from_image(pil_image, image_path)
# Detect source service
source_service = self._detect_source_service(source_metadata)
print(f"MetaMan Load & Convert: Detected source service: {source_service}")
# Convert to target service format
converted_metadata = self._convert_metadata(source_metadata, source_service, target_service)
# Convert PIL image to tensor for ComfyUI
import numpy as np
img_array = np.array(pil_image.convert('RGB')).astype(np.float32) / 255.0
img_tensor = torch.from_numpy(img_array).unsqueeze(0)
print(f"MetaMan Load & Convert: Successfully converted from {source_service} to {target_service}")
return (img_tensor, converted_metadata)
except Exception as e:
print(f"MetaMan Load & Convert Error: {e}")
# Return empty tensor and error message
empty_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
error_msg = f"Error: {str(e)}"
return (empty_tensor, error_msg)
def _extract_metadata_from_image(self, pil_image, file_path):
"""Extract all available metadata from PIL image"""
metadata = {}
try:
# PNG text chunks
if hasattr(pil_image, 'text') and pil_image.text:
print(f"MetaMan Load & Convert: Found PNG chunks: {list(pil_image.text.keys())}")
for key, value in pil_image.text.items():
# Store raw chunk data
metadata[f"png_chunk_{key}"] = value
# Parse specific formats
if key == 'parameters':
# A1111/Civitai parameters
try:
parsed_params = self._parse_a1111_parameters(value)
metadata.update(parsed_params)
except Exception as e:
print(f"MetaMan Load & Convert: Error parsing A1111 parameters: {e}")
elif key in ['workflow', 'prompt']:
# ComfyUI workflow/prompt data
try:
json_data = json.loads(value)
metadata[f"comfyui_{key}"] = json_data
# If this is prompt data, extract parameters immediately
if key == 'prompt':
extracted_params = self._extract_params_from_comfyui_prompt(json_data)
metadata.update(extracted_params)
except Exception as e:
print(f"MetaMan Load & Convert: Error parsing ComfyUI {key}: {e}")
except Exception as e:
print(f"MetaMan Load & Convert: Error extracting metadata: {e}")
return metadata
def _parse_a1111_parameters(self, params_text: str) -> dict:
"""Parse A1111/Civitai parameters format"""
metadata = {}
lines = params_text.strip().split('\n')
if lines:
# First line is usually the positive prompt
metadata['prompt'] = lines[0].strip()
# Look for negative prompt
for i, line in enumerate(lines[1:], 1):
if line.startswith('Negative prompt:'):
metadata['negative_prompt'] = line.replace('Negative prompt:', '').strip()
continue
# Parse parameter line (usually the last line)
if ',' in line and ':' in line:
params = self._parse_parameter_line(line)
metadata.update(params)
return metadata
def _parse_parameter_line(self, line: str) -> dict:
"""Parse A1111 parameter line with improved handling of complex prompts"""
params = {}
# Enhanced splitting that handles quotes, parentheses, and brackets
parts = []
current = ""
in_quotes = False
paren_depth = 0
bracket_depth = 0
for char in line:
if char == '"' and (not current or current[-1] != '\\'):
in_quotes = not in_quotes
elif char == '(' and not in_quotes:
paren_depth += 1
elif char == ')' and not in_quotes:
paren_depth -= 1
elif char == '[' and not in_quotes:
bracket_depth += 1
elif char == ']' and not in_quotes:
bracket_depth -= 1
elif char == ',' and not in_quotes and paren_depth == 0 and bracket_depth == 0:
# Only split on commas that are not inside quotes, parentheses, or brackets
part = current.strip()
if part: # Only add non-empty parts
parts.append(part)
current = ""
continue
current += char
# Don't forget the last part
if current.strip():
parts.append(current.strip())
print(f"MetaMan Debug: Parsed {len(parts)} parameter parts from line")
# Parse each parameter
for part in parts:
part = part.strip()
if not part:
continue
if ':' in part:
# Find the first colon that's not inside parentheses or brackets
colon_pos = -1
paren_depth = 0
bracket_depth = 0
in_quotes = False
for i, char in enumerate(part):
if char == '"' and (i == 0 or part[i-1] != '\\'):
in_quotes = not in_quotes
elif char == '(' and not in_quotes:
paren_depth += 1
elif char == ')' and not in_quotes:
paren_depth -= 1
elif char == '[' and not in_quotes:
bracket_depth += 1
elif char == ']' and not in_quotes:
bracket_depth -= 1
elif char == ':' and not in_quotes and paren_depth == 0 and bracket_depth == 0:
colon_pos = i
break
if colon_pos > 0:
key = part[:colon_pos].strip().lower().replace(' ', '_')
value = part[colon_pos + 1:].strip()
# Remove quotes if present
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
print(f"MetaMan Debug: Parsed parameter '{key}': '{value}' (length: {len(value)})")
# Convert known numeric values
if key in ['steps', 'width', 'height', 'seed', 'clip_skip']:
try:
params[key] = int(value)
except ValueError:
params[key] = value
elif key in ['cfg_scale', 'denoising_strength', 'eta']:
try:
params[key] = float(value)
except ValueError:
params[key] = value
else:
params[key] = value
else:
print(f"MetaMan Debug: Skipping malformed parameter part: '{part}'")
else:
print(f"MetaMan Debug: Skipping non-parameter part: '{part}'")
print(f"MetaMan Debug: Final parsed parameters: {list(params.keys())}")
return params
def _extract_params_from_comfyui_prompt(self, prompt_data: dict) -> dict:
"""Extract generation parameters from ComfyUI prompt data"""
params = {}
try:
for node_id, node_data in prompt_data.items():
if not isinstance(node_data, dict):
continue
class_type = node_data.get('class_type', '')
inputs = node_data.get('inputs', {})
# Extract from KSampler nodes
if class_type == 'KSampler':
if 'steps' in inputs:
params['steps'] = inputs['steps']
if 'cfg' in inputs:
params['cfg_scale'] = inputs['cfg']
if 'sampler_name' in inputs:
params['sampler'] = inputs['sampler_name']
if 'scheduler' in inputs:
params['scheduler'] = inputs['scheduler']
if 'seed' in inputs:
params['seed'] = inputs['seed']
if 'denoise' in inputs:
params['denoising_strength'] = inputs['denoise']
# Extract from CheckpointLoaderSimple
elif class_type == 'CheckpointLoaderSimple':
if 'ckpt_name' in inputs:
params['model_name'] = inputs['ckpt_name']
# Extract from CLIPTextEncode (prompts)
elif class_type == 'CLIPTextEncode':
if 'text' in inputs:
# First positive prompt we find
if 'prompt' not in params and inputs['text'].strip():
params['prompt'] = inputs['text']
# If we already have a prompt, this might be negative
elif 'negative_prompt' not in params and inputs['text'].strip():
# Simple heuristic: if it contains common negative words
negative_indicators = ['worst', 'low quality', 'blurry', 'bad', 'ugly', 'deformed']
if any(indicator in inputs['text'].lower() for indicator in negative_indicators):
params['negative_prompt'] = inputs['text']
# Extract from EmptyLatentImage (dimensions)
elif class_type == 'EmptyLatentImage':
if 'width' in inputs:
params['width'] = inputs['width']
if 'height' in inputs:
params['height'] = inputs['height']
# Extract from LoraLoader nodes
elif class_type == 'LoraLoader':
if 'lora_name' in inputs and 'strength_model' in inputs:
if 'loras' not in params:
params['loras'] = []
lora_info = {
'name': inputs['lora_name'],
'weight': inputs['strength_model']
}
if 'strength_clip' in inputs:
lora_info['clip_weight'] = inputs['strength_clip']
params['loras'].append(lora_info)
except Exception as e:
print(f"MetaMan Load & Convert: Error extracting ComfyUI params: {e}")
return params
def _detect_source_service(self, metadata: dict) -> str:
"""Detect which service generated the image based on metadata"""
# ComfyUI indicators
comfyui_indicators = [
'comfyui_workflow' in metadata,
'comfyui_prompt' in metadata,
any(key.startswith('comfyui_') for key in metadata.keys())
]
if any(comfyui_indicators):
return 'comfyui'
elif 'model_hash' in metadata and 'sampler' in metadata:
return 'automatic1111'
elif 'prompt' in metadata and isinstance(metadata['prompt'], str):
if any(key in metadata for key in ['steps', 'cfg_scale', 'seed']):
return 'automatic1111'
return 'generic'
def _convert_metadata(self, source_metadata: dict, source_service: str, target_service: str) -> str:
"""Convert metadata to target service format"""
try:
if target_service == "automatic1111":
return self._format_a1111_output(source_metadata)
elif target_service == "comfyui":
return self._format_comfyui_output(source_metadata)
else:
# Generic JSON format
filtered = {k: v for k, v in source_metadata.items() if not k.startswith('png_chunk_')}
return json.dumps(filtered, indent=2)
except Exception as e:
return f"Conversion error: {str(e)}"
def _format_a1111_output(self, metadata: dict) -> str:
"""Format metadata for A1111 compatibility"""
lines = []
# Positive prompt
if 'prompt' in metadata:
lines.append(metadata['prompt'])
# Negative prompt
if 'negative_prompt' in metadata:
lines.append(f"Negative prompt: {metadata['negative_prompt']}")
# Parameters line
params = []
param_order = ["steps", "sampler", "cfg_scale", "seed", "width", "height", "model_name"]
for param in param_order:
if param in metadata and metadata[param] is not None:
if param == "cfg_scale":
params.append(f"CFG scale: {metadata[param]}")
elif param == "model_name":
params.append(f"Model: {metadata[param]}")
elif param == "width" and "height" in metadata:
params.append(f"Size: {metadata['width']}x{metadata['height']}")
elif param != "height": # Skip height since it's handled with width
params.append(f"{param.title()}: {metadata[param]}")
if params:
lines.append(", ".join(params))
return "\n".join(lines)
def _format_comfyui_output(self, metadata: dict) -> str:
"""Format metadata for ComfyUI compatibility"""
# For ComfyUI, return the workflow if available
if 'comfyui_workflow' in metadata:
return json.dumps(metadata['comfyui_workflow'], indent=2)
elif 'comfyui_prompt' in metadata:
return json.dumps(metadata['comfyui_prompt'], indent=2)
else:
# Generate basic ComfyUI-compatible metadata
comfyui_meta = {k: v for k, v in metadata.items() if not k.startswith('png_chunk_')}
return json.dumps(comfyui_meta, indent=2)
class MetaManExtractComponents:
"""
Extract individual workflow components from metadata JSON
Perfect for stealing specific elements from images for reuse
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"metadata_json": ("STRING", {"forceInput": True})
}
}
CATEGORY = "MetaMan"
RETURN_TYPES = ("STRING", "STRING", "INT", "FLOAT", "STRING", "STRING", "INT", "INT", "INT", "STRING", "STRING", "*", "*", "FLOAT")
RETURN_NAMES = ("positive_prompt", "negative_prompt", "steps", "cfg_scale", "sampler", "scheduler", "seed", "width", "height", "model_name", "vae_name", "loras", "embeddings", "denoising_strength")
FUNCTION = "extract_components"
DESCRIPTION = "Extract individual workflow components for reuse in current workflow"
def extract_components(self, metadata_json):
"""
Parse metadata JSON and extract individual components
"""
try:
if not metadata_json or not metadata_json.strip():
return self._return_empty_components()
# Parse the JSON from MetaManLoadAndConvert
metadata_data = json.loads(metadata_json)
# Check if this is from our Load & Convert node or raw metadata
if 'metadata' in metadata_data:
metadata = metadata_data['metadata']
else:
metadata = metadata_data
print(f"MetaMan Extract Components: Processing metadata with keys: {list(metadata.keys())}")
# Extract each component with fallbacks
positive_prompt = str(metadata.get('prompt', ''))
negative_prompt = str(metadata.get('negative_prompt', ''))
steps = int(metadata.get('steps', 20))
cfg_scale = float(metadata.get('cfg_scale', 7.0))
sampler = str(metadata.get('sampler', 'euler'))
scheduler = str(metadata.get('scheduler', 'normal'))
seed = int(metadata.get('seed', -1))
width = int(metadata.get('width', 512))
height = int(metadata.get('height', 512))
# Model name with de-obfuscation support
model_name = str(metadata.get('model_name_real', metadata.get('model_name', '')))
# VAE name
vae_name = str(metadata.get('vae_name', ''))
denoising_strength = float(metadata.get('denoising_strength', 1.0))
# Enhanced LoRAs - prefer de-obfuscated version if available (simplified output)
loras_list = []
if 'loras_enhanced' in metadata and isinstance(metadata['loras_enhanced'], list):
# Simplify enhanced LoRAs to only include real_name and weight
raw_loras = metadata['loras_enhanced']
for lora in raw_loras:
if isinstance(lora, dict):
simplified_lora = {
'real_name': lora.get('real_name', lora.get('name', '')),
'weight': lora.get('weight', 1.0)
}
loras_list.append(simplified_lora)
else:
# Handle non-dict LoRAs (fallback)
loras_list.append({'real_name': str(lora), 'weight': 1.0})
print(f"MetaMan Extract Components: Using simplified enhanced LoRAs ({len(loras_list)} LoRAs)")
elif 'loras' in metadata and isinstance(metadata['loras'], list):
# Simplify regular LoRAs to only include name (as real_name) and weight
raw_loras = metadata['loras']
for lora in raw_loras:
if isinstance(lora, dict):
simplified_lora = {
'real_name': lora.get('name', ''),
'weight': lora.get('weight', 1.0)
}
loras_list.append(simplified_lora)
else:
# Handle non-dict LoRAs (fallback)
loras_list.append({'real_name': str(lora), 'weight': 1.0})
print(f"MetaMan Extract Components: Using simplified regular LoRAs ({len(loras_list)} LoRAs)")
# Format embeddings as a list
embeddings_list = []
if 'embeddings' in metadata and isinstance(metadata['embeddings'], list):
embeddings_list = metadata['embeddings'] # Return the actual list
print(f"MetaMan Extract Components: Successfully extracted {len([x for x in [positive_prompt, negative_prompt, model_name, vae_name] if x])} text components")
print(f"MetaMan Extract Components: Extracted {len(loras_list)} simplified LoRAs and {len(embeddings_list)} embeddings")
return (positive_prompt, negative_prompt, steps, cfg_scale, sampler, scheduler, seed, width, height, model_name, vae_name, loras_list, embeddings_list, denoising_strength)
except Exception as e:
print(f"MetaMan Extract Components Error: {e}")
return self._return_empty_components()
def _return_empty_components(self):
"""Return empty/default values for all components"""
return ("", "", 20, 7.0, "euler", "normal", -1, 512, 512, "", "", [], [], 1.0)
class MetaManEmbedAndSave:
"""
Embed metadata into image and save as PNG file to output directory
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"metadata_json": ("STRING", {"forceInput": True}),
},
"optional": {
"filename_prefix": ("STRING", {"default": "MetaMan_converted"})
}
}
CATEGORY = "MetaMan"
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "embed_and_save"
OUTPUT_NODE = True
DESCRIPTION = "Embed metadata into image and save as PNG file"
def embed_and_save(self, image, metadata_json, filename_prefix="MetaMan_converted"):
"""
Embed metadata into image and save as PNG file to output directory
"""
try:
# Parse metadata JSON to extract target format
if metadata_json and metadata_json.strip():
try:
metadata_data = json.loads(metadata_json)
# Extract target metadata or use universal metadata
if 'target_metadata' in metadata_data:
metadata_text = metadata_data['target_metadata']
print(f"MetaMan Embed & Save: Using target metadata ({len(metadata_text)} chars)")
elif 'metadata' in metadata_data:
# Convert metadata dict to A1111 format
metadata_text = self._format_metadata_for_embedding(metadata_data['metadata'])
print(f"MetaMan Embed & Save: Converted metadata to A1111 format ({len(metadata_text)} chars)")
else:
metadata_text = json.dumps(metadata_data, indent=2)
print(f"MetaMan Embed & Save: Using raw JSON ({len(metadata_text)} chars)")
except Exception as e:
print(f"MetaMan Embed & Save: Error parsing metadata JSON: {e}")
metadata_text = metadata_json # Fallback to raw input
else:
metadata_text = "MetaMan processed image"
print(f"MetaMan Embed & Save: No metadata provided, using default")
# Convert tensor to PIL Image
if isinstance(image, torch.Tensor):
img_tensor = image[0]
img_array = (img_tensor.cpu().numpy() * 255).astype('uint8')
pil_image = Image.fromarray(img_array)
else:
pil_image = image
# Save to ComfyUI output directory with metadata
output_dir = folder_paths.get_output_directory()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{filename_prefix}_{timestamp}.png"
filepath = os.path.join(output_dir, filename)
# Create PNG info with metadata
png_info = PngInfo()
png_info.add_text("parameters", metadata_text)
png_info.add_text("metaman_converted", datetime.now().isoformat())
png_info.add_text("metaman_version", "1.0.0")
# Save image with embedded metadata
pil_image.save(filepath, "PNG", pnginfo=png_info)
print(f"MetaMan Embed & Save: Successfully saved image with metadata to {filepath}")
print(f"MetaMan Embed & Save: File size: {os.path.getsize(filepath)} bytes")
return {"ui": {"images": [{"filename": filename, "subfolder": "", "type": "output"}]}}
except Exception as e:
print(f"MetaMan Embed & Save Error: {e}")
return {"ui": {"text": [f"Error: {str(e)}"]}}
def _format_metadata_for_embedding(self, metadata: dict) -> str:
"""Convert metadata dict to A1111 format for embedding"""
lines = []
# Positive prompt
if 'prompt' in metadata:
lines.append(metadata['prompt'])
# Negative prompt
if 'negative_prompt' in metadata:
lines.append(f"Negative prompt: {metadata['negative_prompt']}")
# Parameters line
params = []
param_order = ["steps", "sampler", "cfg_scale", "seed", "width", "height", "model_name", "scheduler"]
for param in param_order:
if param in metadata and metadata[param] is not None:
if param == "cfg_scale":
params.append(f"CFG scale: {metadata[param]}")
elif param == "model_name":
params.append(f"Model: {metadata[param]}")
elif param == "width" and "height" in metadata:
params.append(f"Size: {metadata['width']}x{metadata['height']}")
elif param != "height": # Skip height since it's handled with width
params.append(f"{param.title()}: {metadata[param]}")
if params:
lines.append(", ".join(params))
return "\n".join(lines)
class MetaManLoadImage:
"""
Custom Load Image node that preserves PNG metadata
Designed to work with MetaMan Universal node
"""
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
return {
"required": {
"image": (sorted(files), {"image_upload": True})
}
}
CATEGORY = "MetaMan"
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "metadata_json")
FUNCTION = "load_image_with_metadata"
DESCRIPTION = "Load image while preserving PNG metadata for MetaMan"
def load_image_with_metadata(self, image):
"""
Load image and extract metadata, returning both
"""
try:
input_dir = folder_paths.get_input_directory()
image_path = folder_paths.get_annotated_filepath(image, input_dir)
print(f"MetaMan Load Image: Loading {image_path}")
# Load image with PIL to preserve metadata
pil_image = Image.open(image_path)
print(f"MetaMan Load Image: Image size: {pil_image.size}")
print(f"MetaMan Load Image: Image format: {pil_image.format}")
# Extract metadata from the original file
metadata = self._extract_metadata_from_image(pil_image, image_path)
# Convert PIL image to tensor for ComfyUI
import numpy as np
img_array = np.array(pil_image.convert('RGB')).astype(np.float32) / 255.0
img_tensor = torch.from_numpy(img_array).unsqueeze(0)
# Create metadata JSON output
metadata_json = {
"source_file_path": image_path,
"image_size": pil_image.size,
"image_format": pil_image.format,
"metadata": metadata,
"extracted_at": datetime.now().isoformat()
}
print(f"MetaMan Load Image: Extracted metadata keys: {list(metadata.keys())}")
return (img_tensor, json.dumps(metadata_json, indent=2))
except Exception as e:
print(f"MetaMan Load Image Error: {e}")
# Return empty tensor and error message
empty_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
error_json = {"error": str(e), "source_file_path": image_path if 'image_path' in locals() else "unknown"}
return (empty_tensor, json.dumps(error_json, indent=2))
def _extract_metadata_from_image(self, pil_image, file_path):
"""Extract all available metadata from PIL image"""
metadata = {}
try:
# PNG text chunks
if hasattr(pil_image, 'text') and pil_image.text:
print(f"MetaMan Load Image: Found PNG text chunks: {list(pil_image.text.keys())}")
for key, value in pil_image.text.items():
print(f"MetaMan Load Image: Chunk '{key}': {len(value)} characters")
# Store raw chunk data
metadata[f"png_chunk_{key}"] = value
# Parse specific formats
if key == 'parameters':
# A1111/Civitai parameters
try:
parsed_params = self._parse_a1111_parameters(value)
metadata.update(parsed_params)
print(f"MetaMan Load Image: Parsed A1111 parameters: {list(parsed_params.keys())}")
except Exception as e:
print(f"MetaMan Load Image: Error parsing A1111 parameters: {e}")
elif key in ['workflow', 'prompt']:
# ComfyUI workflow/prompt data
try:
json_data = json.loads(value)
metadata[f"comfyui_{key}"] = json_data
print(f"MetaMan Load Image: Parsed ComfyUI {key}: {len(json_data)} nodes")
# If this is prompt data, extract parameters immediately
if key == 'prompt':
extracted_params = self._extract_params_from_comfyui_prompt(json_data)
metadata.update(extracted_params)
# Phase: De-obfuscate Tensor.AI model names if available (after parameter extraction)
if extracted_params:
deobfuscated_info = self._deobfuscate_tensor_ai_models(metadata, extracted_params.get('loras', []))
if deobfuscated_info:
metadata.update(deobfuscated_info)
print(f"MetaMan Load Image: De-obfuscated Tensor.AI models: {list(deobfuscated_info.keys())}")
print(f"MetaMan Load Image: Extracted ComfyUI params: {list(extracted_params.keys())}")
except Exception as e:
print(f"MetaMan Load Image: Error parsing ComfyUI {key}: {e}")
elif key == 'meta':
# MetaMan universal format
try:
meta_data = json.loads(value)
metadata.update(meta_data)
print(f"MetaMan Load Image: Parsed MetaMan meta chunk")
except Exception as e:
print(f"MetaMan Load Image: Error parsing meta chunk: {e}")
else:
print(f"MetaMan Load Image: No PNG text chunks found")
# Check image.info as fallback
if hasattr(pil_image, 'info') and pil_image.info:
print(f"MetaMan Load Image: Found image.info keys: {list(pil_image.info.keys())}")
for key, value in pil_image.info.items():
if isinstance(value, str) and len(value) > 50:
metadata[f"info_{key}"] = value
print(f"MetaMan Load Image: Stored info['{key}']: {len(value)} characters")
except Exception as e:
print(f"MetaMan Load Image: Error extracting metadata: {e}")
return metadata
def _parse_a1111_parameters(self, params_text: str) -> dict:
"""Parse A1111/Civitai parameters format"""
metadata = {}
lines = params_text.strip().split('\n')
if lines:
# First line is usually the positive prompt
metadata['prompt'] = lines[0].strip()
# Look for negative prompt
for i, line in enumerate(lines[1:], 1):
if line.startswith('Negative prompt:'):
metadata['negative_prompt'] = line.replace('Negative prompt:', '').strip()
continue
# Parse parameter line (usually the last line)
if ',' in line and ':' in line:
params = self._parse_parameter_line(line)
metadata.update(params)
return metadata
def _parse_parameter_line(self, line: str) -> dict:
"""Parse A1111 parameter line"""
params = {}
# Split by comma, but be careful of commas in quoted values
parts = []
current = ""
in_quotes = False
for char in line:
if char == '"' and (not current or current[-1] != '\\'):
in_quotes = not in_quotes
elif char == ',' and not in_quotes:
parts.append(current.strip())
current = ""
continue
current += char
if current.strip():
parts.append(current.strip())
# Parse each parameter
for part in parts:
if ':' in part:
key, value = part.split(':', 1)
key = key.strip().lower().replace(' ', '_')
value = value.strip()
# Convert known numeric values
if key in ['steps', 'width', 'height', 'seed', 'clip_skip']:
try:
params[key] = int(value)
except:
params[key] = value
elif key in ['cfg_scale', 'denoising_strength', 'eta']:
try:
params[key] = float(value)
except:
params[key] = value
else:
params[key] = value
return params
def _extract_params_from_comfyui_prompt(self, prompt_data: dict) -> dict:
"""Universal dynamic extraction from ComfyUI prompt data - works with any node ecosystem"""
params = {}
print(f"MetaMan Universal: Starting extraction from {len(prompt_data)} nodes")
try:
# Phase 1: Extract core parameters universally
self._extract_core_parameters_universal(prompt_data, params)
# Phase 2: Collect ALL text content from all nodes
all_text_content = self._scan_all_text_content_universal(prompt_data)
print(f"MetaMan Universal: Found {len(all_text_content)} text candidates")
# Phase 3: Classify text content using improved heuristics
classified_prompts = self._classify_prompts_intelligent(all_text_content)
# Phase 4: Extract LoRAs from all text content
all_loras = self._extract_loras_universal(all_text_content)
print(f"MetaMan Universal: DEBUG - LoRAs extraction returned: {all_loras}")
# Phase 5: Extract embeddings from all text content
all_embeddings = self._extract_embeddings_universal(all_text_content)
print(f"MetaMan Universal: DEBUG - Embeddings extraction returned: {all_embeddings}")
# Phase 6: Resolve model names by tracing connections
model_info = self._resolve_model_names(prompt_data)
if model_info:
params.update(model_info)
print(f"MetaMan Universal: Resolved models: {model_info}")
# Phase 6.7: Extract VAE information
vae_info = self._extract_vae_info(prompt_data)
if vae_info:
params.update(vae_info)
print(f"MetaMan Universal: Extracted VAE: {vae_info}")
# Phase 6.8: Determine model type from VAE (helps identify SD1.5 vs SDXL vs Flux)
model_type = self._determine_model_type(prompt_data)
if model_type:
params['model_type'] = model_type
print(f"MetaMan Universal: Detected model type: {model_type}")
# Phase 7: Compile final results
if classified_prompts['positive']:
params['prompt'] = classified_prompts['positive']['text']
print(f"MetaMan Universal: POSITIVE from {classified_prompts['positive']['source']}: {params['prompt'][:100]}...")
if classified_prompts['negative']:
params['negative_prompt'] = classified_prompts['negative']['text']
print(f"MetaMan Universal: NEGATIVE from {classified_prompts['negative']['source']}: {params['negative_prompt'][:100]}...")
if all_loras:
params['loras'] = all_loras
print(f"MetaMan Universal: Extracted {len(all_loras)} LoRAs: {[lora['name'] for lora in all_loras]}")
if all_embeddings:
params['embeddings'] = all_embeddings
print(f"MetaMan Universal: Extracted {len(all_embeddings)} embeddings: {[emb['name'] for emb in all_embeddings]}")
print(f"MetaMan Universal: Extraction complete: {list(params.keys())}")
except Exception as e:
print(f"MetaMan Universal Extraction Error: {e}")
import traceback
traceback.print_exc()
return params
def _extract_core_parameters_universal(self, prompt_data: dict, params: dict):
"""Extract core parameters from all nodes universally"""
print(f"MetaMan Universal: Extracting core parameters...")
for node_id, node_data in prompt_data.items():
if not isinstance(node_data, dict):
continue
class_type = node_data.get('class_type', '')
inputs = node_data.get('inputs', {})
self._extract_core_parameters_from_node(inputs, class_type, params, node_id)
def _extract_core_parameters_from_node(self, inputs: dict, class_type: str, params: dict, node_id: str):
"""Extract core parameters from any node type"""
# Universal parameter mapping with broader coverage
param_mappings = {
'steps': ['steps', 'sampling_steps', 'num_steps', 'inference_steps'],
'cfg_scale': ['cfg', 'cfg_scale', 'guidance_scale', 'guidance'],
'sampler': ['sampler_name', 'sampler', 'sampling_method', 'sampler_type'],
'scheduler': ['scheduler', 'scheduler_name', 'noise_schedule', 'scheduler_type'],
'seed': ['seed', 'noise_seed', 'random_seed', 'generator_seed'],
'denoising_strength': ['denoise', 'denoising_strength', 'strength', 'denoise_strength'],
'width': ['width', 'image_width', 'w', 'img_width'],
'height': ['height', 'image_height', 'h', 'img_height']
}
for param_key, possible_fields in param_mappings.items():
if param_key not in params: # Don't overwrite existing values
for field in possible_fields:
if field in inputs and inputs[field] is not None:
params[param_key] = inputs[field]
print(f"MetaMan Universal: Found {param_key} = {inputs[field]} in {node_id} ({class_type})")
break
# Special handling for model_name - don't extract node references here
# Model resolution will be handled separately in _resolve_model_names()
def _extract_loras_from_text(self, text: str) -> list:
"""Extract LoRA information from text using regex"""
import re
loras = []
# Pattern for <lora:name:weight> or <lora:name>
lora_pattern = r'<lora:([^>:]+)(?::([0-9.]+))?[^>]*>'
matches = re.findall(lora_pattern, text, re.IGNORECASE)
for match in matches:
name = match[0].strip()
weight = float(match[1]) if match[1] else 1.0
loras.append({
'name': name,
'weight': weight
})
return loras
def _scan_all_text_content_universal(self, prompt_data: dict) -> list:
"""Scan ALL nodes for text content with enhanced detection"""
text_candidates = []
for node_id, node_data in prompt_data.items():
if not isinstance(node_data, dict):
continue
class_type = node_data.get('class_type', '')
inputs = node_data.get('inputs', {})
# Enhanced text field detection
for field_name, field_value in inputs.items():
if isinstance(field_value, str) and len(field_value.strip()) > 5:
text_candidates.append({
'text': field_value,
'source': f"{node_id}.{field_name}",
'node_id': node_id,
'field_name': field_name,
'node_type': class_type,
'length': len(field_value),
'is_text_field': self._is_likely_text_field(field_name),
'is_processed_field': self._is_processed_text_field(field_name)
})
print(f"MetaMan Universal: Found text in {node_id}.{field_name} ({class_type}): {len(field_value)} chars")
return text_candidates
def _is_likely_text_field(self, field_name: str) -> bool:
"""Check if field name indicates text content"""
text_indicators = [
'text', 'prompt', 'positive', 'negative', 'content', 'description',
'caption', 'wildcard', 'populated', 'formatted', 'input', 'output'
]
field_lower = field_name.lower()
return any(indicator in field_lower for indicator in text_indicators)
def _is_processed_text_field(self, field_name: str) -> bool:
"""Check if field name indicates processed/formatted text"""
processed_indicators = ['populated', 'formatted', 'processed', 'final', 'output', 'result']
field_lower = field_name.lower()
return any(indicator in field_lower for indicator in processed_indicators)
def _classify_prompts_intelligent(self, text_candidates: list) -> dict:
"""Intelligent classification of text content into positive/negative prompts"""
classified = {'positive': None, 'negative': None}
# Score all candidates for positive/negative likelihood
positive_candidates = []
negative_candidates = []
for candidate in text_candidates:
if len(candidate['text']) < 20: # Skip very short text
continue
pos_score = self._score_as_positive_prompt(candidate)
neg_score = self._score_as_negative_prompt(candidate)
candidate['positive_score'] = pos_score
candidate['negative_score'] = neg_score
print(f"MetaMan Universal: {candidate['source']} - Pos: {pos_score:.1f}, Neg: {neg_score:.1f}")
if pos_score > 0.5:
positive_candidates.append(candidate)
if neg_score > 0.5:
negative_candidates.append(candidate)
# Select best positive prompt
if positive_candidates:
positive_candidates.sort(key=lambda x: x['positive_score'], reverse=True)
classified['positive'] = positive_candidates[0]
print(f"MetaMan Universal: Best positive: {classified['positive']['source']} (score: {classified['positive']['positive_score']:.1f})")
# Select best negative prompt
if negative_candidates:
negative_candidates.sort(key=lambda x: x['negative_score'], reverse=True)
classified['negative'] = negative_candidates[0]
print(f"MetaMan Universal: Best negative: {classified['negative']['source']} (score: {classified['negative']['negative_score']:.1f})")
return classified
def _score_as_positive_prompt(self, candidate: dict) -> float:
"""Score text as likely positive prompt with enhanced detection for processed fields"""
score = 0.0
text = candidate['text']
text_lower = text.lower()
field_name = candidate['field_name'].lower()
node_type = candidate['node_type']
# CRITICAL: Exclude LoRA-only content from prompt scoring
if self._is_lora_only_content(text, node_type):
print(f"MetaMan Universal: EXCLUDING LoRA-only content from prompt scoring: {candidate['source']}")
return 0.0 # LoRA-only content should not be considered as positive prompt
# CRITICAL: Strong negative indicators should disqualify from positive scoring
if self._is_clearly_negative_content(text):
print(f"MetaMan Universal: EXCLUDING clearly negative content from positive scoring: {candidate['source']}")
return 0.0 # Obviously negative content should not be positive prompt
# ENHANCED: Field name indicators (strong signals)
if 'positive' in field_name or 'prompt' in field_name:
score += 3.0
# PRIORITY: Processed/populated fields are very likely to be the main prompt
if 'populated' in field_name or 'formatted' in field_name or 'processed' in field_name:
score += 4.0 # Increased from 2.0 - these are usually the complete prompt
print(f"MetaMan Universal: HIGH PRIORITY processed field detected: {candidate['source']}")
# Custom node field detection
if 'wildcard' in field_name or 'impact' in node_type.lower():
score += 3.0 # ImpactWildcardEncode is a common complete prompt source
if 'text' in field_name:
score += 1.0
# Content indicators
positive_keywords = [
'score_9', 'score_8', 'masterpiece', 'best quality', 'high resolution',
'detailed', 'beautiful', 'professional', 'cinematic', 'photorealistic',
'ultra', 'highly detailed', 'intricate', 'sharp focus', 'realistic'
]
keyword_count = sum(1 for keyword in positive_keywords if keyword in text_lower)
score += keyword_count * 0.5
# LoRA presence (moderate indicator of main prompt, but not if it's LoRA-only)
if '<lora:' in text:
# Count human-readable content vs LoRA content
human_content = self._extract_human_readable_content(text)
if len(human_content) > 20: # Has substantial human content
lora_count = text.count('<lora:')
score += lora_count * 1.0 # Reduced from 2.0 - LoRAs are good but not overwhelming
print(f"MetaMan Universal: Found {lora_count} LoRAs with human content in {candidate['source']} (+{lora_count} score)")
else:
print(f"MetaMan Universal: LoRAs found but minimal human content in {candidate['source']} (no LoRA bonus)")
# ENHANCED: Length scoring with better thresholds (but capped to prevent negative dominance)
text_length = len(text)
if text_length > 1000:
score += 1.5 # Reduced for very long text - might be negative prompt
elif text_length > 500:
score += 2.5 # Reduced
elif text_length > 200:
score += 2.0
elif text_length > 100:
score += 1.0
elif text_length > 50:
score += 0.5
# BREAK indicators (common in ComfyUI prompts)
if 'BREAK' in text.upper():
score += 1.0 # BREAK is common in detailed positive prompts
# Enhanced negative indicators (reduce score more aggressively)
negative_keywords = ['worst', 'low quality', 'bad', 'ugly', 'blurry', 'deformed']
negative_count = sum(1 for keyword in negative_keywords if keyword in text_lower)
if negative_count > 2:
score -= 3.0 # Increased penalty - likely negative prompt
if negative_count > 5:
score -= 5.0 # Very likely negative prompt
return max(0.0, score)
def _is_clearly_negative_content(self, text: str) -> bool:
"""Detect obviously negative content that should never be positive prompt"""
text_lower = text.lower()
# Check for embedding syntax
if 'embedding:' in text_lower:
print(f"MetaMan Universal: Found embedding syntax - likely negative prompt")
return True
# Well-known negative embedding/model names
negative_embeddings = [
'baddream', 'fastnegative', 'easynegative', 'verybadimagenegative',
'ng_deepnegative', 'negative_hand', 'bad-hands', 'badhandv4',
'unrealengine', 'badprompt', 'bad-artist', 'bad_prompt_version'
]
for neg_embed in negative_embeddings:
if neg_embed in text_lower:
print(f"MetaMan Universal: Found negative embedding '{neg_embed}' - clearly negative content")
return True
# Count negative keywords as percentage of content
negative_keywords = [
'worst', 'low quality', 'bad', 'ugly', 'blurry', 'deformed', 'distorted',
'mutation', 'mutated', 'disfigured', 'dismembered', 'malformed',
'poorly drawn', 'extra', 'missing', 'cropped', 'watermark', 'text',
'signature', 'username', 'lowres', 'artifacts', 'duplicate', 'morbid',
'mutilated', 'fused fingers', 'unclear eyes', 'bad anatomy', 'bad hands'
]
neg_count = sum(1 for keyword in negative_keywords if keyword in text_lower)
# If text has high concentration of negative keywords, it's clearly negative
if neg_count > 10: # Lots of negative keywords
print(f"MetaMan Universal: High negative keyword density ({neg_count}) - clearly negative content")
return True
# If negative keywords make up significant portion of unique words
words = set(text_lower.split())
if len(words) > 0 and neg_count / len(words) > 0.3: # 30% negative keywords
print(f"MetaMan Universal: High negative keyword ratio ({neg_count}/{len(words)}) - clearly negative content")
return True
return False
def _is_lora_only_content(self, text: str, node_type: str) -> bool:
"""Check if text contains only LoRA tags and no meaningful human content"""
# Check node type first - LoraTagLoader is specifically for LoRA loading
if 'lora' in node_type.lower() and 'tag' in node_type.lower():
return True
# Check if text is primarily LoRA tags
human_content = self._extract_human_readable_content(text)
lora_content_length = len(text) - len(human_content)
# If most of the content is LoRA tags and little human content, consider it LoRA-only
if len(human_content.strip()) < 10 and lora_content_length > len(human_content) * 2:
return True
return False
def _extract_human_readable_content(self, text: str) -> str:
"""Extract human-readable content by removing LoRA tags"""
import re
# Remove LoRA tags
no_loras = re.sub(r'<lora:[^>]*>', '', text, flags=re.IGNORECASE)
# Remove extra whitespace and commas
cleaned = re.sub(r'[,\s]+', ' ', no_loras).strip()
return cleaned
def _score_as_negative_prompt(self, candidate: dict) -> float:
"""Score text as likely negative prompt"""
score = 0.0
text = candidate['text']
text_lower = text.lower()
field_name = candidate['field_name'].lower()
# Field name indicators
if 'negative' in field_name:
score += 5.0 # Very strong indicator
elif 'bad' in field_name or 'unwanted' in field_name:
score += 3.0
# Content analysis - negative keywords
negative_keywords = [
'worst quality', 'low quality', 'bad', 'ugly', 'blurry', 'deformed',
'distorted', 'mutation', 'error', 'artifact', 'disfigured',
'extra limbs', 'missing', 'cropped', 'watermark', 'text', 'signature',
'username', 'lowres', 'jpeg artifacts', 'duplicate', 'morbid',
'mutilated', 'poorly drawn', 'extra fingers', 'fused fingers',
'too many fingers', 'unclear eyes', 'lowers', 'bad anatomy',
'bad hands', 'missing fingers', 'extra digit', 'fewer digits'
]
keyword_count = sum(1 for keyword in negative_keywords if keyword in text_lower)
score += keyword_count * 0.7
# Pattern analysis - negative prompts often have parentheses with weights
parentheses_content = re.findall(r'\([^)]+:[0-9.]+\)', text)
if parentheses_content:
score += len(parentheses_content) * 0.5
# Short text with negative words likely negative prompt
if len(text) < 200 and keyword_count > 1:
score += 1.0
# Positive indicators (reduce score)
positive_keywords = ['masterpiece', 'best quality', 'detailed', 'beautiful']
positive_count = sum(1 for keyword in positive_keywords if keyword in text_lower)
if positive_count > 1:
score -= 1.5 # Likely positive prompt
# LoRA presence (usually in positive prompts)
if '<lora:' in text:
score -= 2.0 # LoRAs typically in positive prompts
return max(0.0, score)
def _extract_loras_from_text(self, text: str) -> list:
"""Extract LoRA information from text using regex"""
import re
loras = []
# Pattern for <lora:name:weight> or <lora:name>
lora_pattern = r'<lora:([^>:]+)(?::([0-9.]+))?[^>]*>'
matches = re.findall(lora_pattern, text, re.IGNORECASE)
for match in matches:
name = match[0].strip()
weight = float(match[1]) if match[1] else 1.0
loras.append({
'name': name,
'weight': weight
})
return loras
def _extract_loras_universal(self, text_candidates: list) -> list:
"""Extract LoRAs from all text content"""
all_loras = []
seen_loras = set()
for candidate in text_candidates:
loras_in_text = self._extract_loras_from_text(candidate['text'])
for lora in loras_in_text:
lora_key = lora['name'].lower()
if lora_key not in seen_loras:
seen_loras.add(lora_key)
all_loras.append(lora)
print(f"MetaMan Universal: Found LoRA '{lora['name']}' (weight: {lora['weight']}) in {candidate['source']}")
return all_loras
def _extract_embeddings_universal(self, text_candidates: list) -> list:
"""Extract embeddings from all text content"""
all_embeddings = []
seen_embeddings = set()
for candidate in text_candidates:
embeddings_in_text = self._extract_embeddings_from_text(candidate['text'])
for embedding in embeddings_in_text:
embedding_key = embedding['name'].lower()
if embedding_key not in seen_embeddings:
seen_embeddings.add(embedding_key)
all_embeddings.append(embedding)
print(f"MetaMan Universal: Found embedding '{embedding['name']}' in {candidate['source']}")
return all_embeddings
def _extract_embeddings_from_text(self, text: str) -> list:
"""Extract embedding information from text using regex and known patterns"""
import re
embeddings = []
text_lower = text.lower()
# Pattern 1: embedding:name syntax
embedding_pattern = r'embedding:([\w\-_\.]+)'
matches = re.findall(embedding_pattern, text, re.IGNORECASE)
for match in matches:
name = match.strip()
embeddings.append({
'name': name,
'type': 'embedding'
})
# Pattern 2: Well-known negative embedding names (even without embedding: prefix)
negative_embeddings = [
'baddream', 'fastnegative', 'fastnegativev2', 'easynegative', 'verybadimagenegative',
'ng_deepnegative', 'negative_hand', 'bad-hands', 'badhandv4', 'badhandv5',
'unrealengine', 'badprompt', 'bad-artist', 'bad_prompt_version', 'badpromptversion',
'ng_deepnegative_v1_75t', 'negativexl', 'ac_neg1', 'ac_neg2'
]
for neg_embed in negative_embeddings:
# Look for exact word matches (not partial)
pattern = r'\b' + re.escape(neg_embed) + r'\b'
if re.search(pattern, text_lower):
embedding_name = neg_embed
# If found via pattern, use the actual case from text if possible
actual_match = re.search(pattern, text, re.IGNORECASE)
if actual_match:
embedding_name = actual_match.group()
embeddings.append({
'name': embedding_name,
'type': 'negative_embedding'
})
return embeddings
def _resolve_model_names(self, prompt_data: dict) -> dict:
"""Resolve actual model names by tracing node connections from samplers"""
model_info = {}
try:
# Find all sampler nodes (they determine which models are actually used)
sampler_nodes = []
for node_id, node_data in prompt_data.items():
if not isinstance(node_data, dict):
continue
class_type = node_data.get('class_type', '')
if 'sampler' in class_type.lower() or class_type == 'KSampler':
sampler_nodes.append((node_id, node_data))
print(f"MetaMan Universal: Found sampler node {node_id} ({class_type})")
if not sampler_nodes:
print(f"MetaMan Universal: No sampler nodes found")
return model_info
# For each sampler, trace the model connection
used_models = []
for node_id, node_data in sampler_nodes:
inputs = node_data.get('inputs', {})
# Look for model input (usually a node reference)
model_input = inputs.get('model')
if model_input and isinstance(model_input, list) and len(model_input) >= 2:
source_node_id = str(model_input[0])
print(f"MetaMan Universal: Sampler {node_id} uses model from node {source_node_id}")
# Find the source node and extract the model name
model_name = self._trace_model_source(prompt_data, source_node_id)
if model_name:
used_models.append(model_name)
print(f"MetaMan Universal: Traced to model: {model_name}")
# Remove duplicates and set model info
unique_models = []
for model in used_models:
# Skip None values and node references that weren't resolved
if model and isinstance(model, str) and not model.startswith('['):
if model not in unique_models:
unique_models.append(model)
if unique_models:
if len(unique_models) == 1:
model_info['model_name'] = unique_models[0]
else:
model_info['model_name'] = unique_models[0] # Primary model
model_info['additional_models'] = unique_models[1:] # Additional models
print(f"MetaMan Universal: Final model resolution: {unique_models}")
else:
print(f"MetaMan Universal: No resolvable model names found")
except Exception as e:
print(f"MetaMan Universal: Error resolving model names: {e}")
return model_info
def _trace_model_source(self, prompt_data: dict, node_id: str) -> str:
"""Trace a node to find the actual model name"""
try:
if node_id not in prompt_data:
return None
node_data = prompt_data[node_id]
if not isinstance(node_data, dict):
return None
class_type = node_data.get('class_type', '')
inputs = node_data.get('inputs', {})
print(f"MetaMan Universal: Tracing model source in node {node_id} ({class_type})")
# Check different model loader types
model_fields = [
'ckpt_name', 'model_name', 'checkpoint_name',
'unet_name', 'model', 'checkpoint'
]
for field in model_fields:
if field in inputs and inputs[field]:
model_value = inputs[field]
# Check if this is a string (actual model name) or a list (node reference)
if isinstance(model_value, str):
print(f"MetaMan Universal: Found model '{model_value}' in {node_id}.{field}")
return model_value
elif isinstance(model_value, list) and len(model_value) >= 2:
# This is a node reference, trace further
source_node_id = str(model_value[0])
print(f"MetaMan Universal: Node {node_id}.{field} references node {source_node_id}, tracing further...")
return self._trace_model_source(prompt_data, source_node_id)
# If this node doesn't have a direct model name, look for model input that might be a reference to another node
model_input = inputs.get('model')
if model_input and isinstance(model_input, list) and len(model_input) >= 2:
source_node_id = str(model_input[0])
print(f"MetaMan Universal: Node {node_id} model input references node {source_node_id}, tracing further...")
return self._trace_model_source(prompt_data, source_node_id)
return None
except Exception as e:
print(f"MetaMan Universal: Error tracing model source for node {node_id}: {e}")
return None
def _determine_model_type(self, prompt_data: dict) -> str:
"""Determine model type (SD1.5, SDXL, Flux, etc.) by analyzing VAE and other indicators"""
try:
# Look for VAE nodes and their model names
for node_id, node_data in prompt_data.items():
if not isinstance(node_data, dict):
continue
class_type = node_data.get('class_type', '')
inputs = node_data.get('inputs', {})
# Check VAE loaders
if 'vae' in class_type.lower():
vae_name = inputs.get('vae_name', '')
if vae_name:
vae_lower = vae_name.lower()
if 'ae.safetensors' in vae_lower or 'flux' in vae_lower:
return 'Flux'
elif 'sdxl' in vae_lower or 'xl' in vae_lower:
return 'SDXL'
elif any(sd15_indicator in vae_lower for sd15_indicator in ['sd15', 'v1-5', 'vae-ft']):
return 'SD1.5'
# Check for SD3/Flux specific nodes
if class_type in ['EmptySD3LatentImage', 'SD3LatentImage']:
return 'SD3/Flux'
# Check DualCLIPLoader (Flux indicator)
if class_type == 'DualCLIPLoader':
clip_name1 = inputs.get('clip_name1', '')
clip_name2 = inputs.get('clip_name2', '')
if 't5xxl' in clip_name2.lower():
return 'Flux'
# Check for UNet loaders (often Flux GGUF)
if 'unet' in class_type.lower():
unet_name = inputs.get('unet_name', '')
if 'flux' in unet_name.lower():
return 'Flux'
# Fallback: look at latent image dimensions
for node_id, node_data in prompt_data.items():
if not isinstance(node_data, dict):
continue
class_type = node_data.get('class_type', '')
inputs = node_data.get('inputs', {})
if 'latent' in class_type.lower() and 'empty' in class_type.lower():
width = inputs.get('width', 0)
height = inputs.get('height', 0)
# Flux typically uses 1024x1024 or similar
if width >= 1024 or height >= 1024:
return 'Flux/SDXL' # Could be either
elif width <= 512 and height <= 512:
return 'SD1.5'
return 'Unknown'
except Exception as e:
print(f"MetaMan Universal: Error determining model type: {e}")
return 'Unknown'
def _deobfuscate_tensor_ai_models(self, metadata: dict, loras: list) -> dict:
"""De-obfuscate Tensor.AI EMS model codes using generation_data chunk"""
deobfuscated = {}
try:
# Look for Tensor.AI generation_data chunk
generation_data_raw = metadata.get('png_chunk_generation_data') or metadata.get('info_generation_data')
if not generation_data_raw:
print(f"MetaMan De-obfuscation: No generation_data found")
return deobfuscated
print(f"MetaMan De-obfuscation: Found generation_data ({len(generation_data_raw)} chars)")
# Clean the JSON string - remove null bytes and strip whitespace
cleaned_data = generation_data_raw.replace('\u0000', '').replace('\x00', '').strip()
print(f"MetaMan De-obfuscation: Cleaned data ({len(cleaned_data)} chars)")
# Parse the generation data JSON
import json
generation_data = json.loads(cleaned_data)
# Extract model mappings from generation_data
models_data = generation_data.get('models', [])
base_model_data = generation_data.get('baseModel', {})
print(f"MetaMan De-obfuscation: Found {len(models_data)} LoRAs and base model in generation_data")
# Create EMS to real name mapping
ems_mapping = {}
# Map LoRAs
for i, model_data in enumerate(models_data):
if model_data.get('type') == 'LORA':
real_name = model_data.get('modelFileName', '')
model_hash = model_data.get('hash', '')
weight = model_data.get('weight', 1.0)
label = model_data.get('label', '')
# Store mapping info
ems_mapping[real_name] = {
'hash': model_hash,
'weight': weight,
'label': label,
'type': 'lora',
'index': i
}
print(f"MetaMan De-obfuscation: LoRA {i} mapping - {real_name} (hash: {model_hash[:16]}...)")
# Map base model
if base_model_data:
real_name = base_model_data.get('modelFileName', '')
model_hash = base_model_data.get('hash', '')
label = base_model_data.get('label', '')
if real_name:
ems_mapping[real_name] = {
'hash': model_hash,
'label': label,
'type': 'checkpoint'
}
# Set the real checkpoint name
deobfuscated['model_name_real'] = real_name
deobfuscated['model_hash'] = model_hash
deobfuscated['model_label'] = label
print(f"MetaMan De-obfuscation: Checkpoint mapping - {real_name} (hash: {model_hash[:16]}...)")
# Apply de-obfuscation to LoRAs
if loras and models_data:
deobfuscated_loras = []
print(f"MetaMan De-obfuscation: Mapping {len(loras)} LoRAs with {len(models_data)} model data entries")
# Map LoRAs by order/index since EMS names don't match real names
for i, lora in enumerate(loras):
lora_name = lora.get('name', '') if isinstance(lora, dict) else str(lora)
lora_weight = lora.get('weight', 1.0) if isinstance(lora, dict) else 1.0
# Find corresponding model data by index
if i < len(models_data):
model_data = models_data[i]
if model_data.get('type') == 'LORA':
real_name = model_data.get('modelFileName', '')
enhanced_lora = {
'name': lora_name, # Keep original EMS name
'real_name': real_name, # Add real name
'weight': lora_weight, # Use weight from extraction
'hash': model_data.get('hash', ''),
'label': model_data.get('label', '')
}
deobfuscated_loras.append(enhanced_lora)
print(f"MetaMan De-obfuscation: Enhanced LoRA {i} - {lora_name} → {real_name}")
else:
deobfuscated_loras.append(lora)
print(f"MetaMan De-obfuscation: Model data {i} not LoRA type, keeping original: {lora_name}")
else:
# Keep original if no mapping found
deobfuscated_loras.append(lora)
print(f"MetaMan De-obfuscation: No model data for LoRA {i}, keeping original: {lora_name}")
if deobfuscated_loras:
deobfuscated['loras_enhanced'] = deobfuscated_loras
print(f"MetaMan De-obfuscation: Created {len(deobfuscated_loras)} enhanced LoRAs")
# Store the EMS mapping for future reference
if ems_mapping:
deobfuscated['tensor_ai_mappings'] = ems_mapping
print(f"MetaMan De-obfuscation: Stored {len(ems_mapping)} mappings")
except json.JSONDecodeError as e:
print(f"MetaMan De-obfuscation JSON Error: {e}")
print(f"MetaMan De-obfuscation: Problematic data around char {e.pos}: '{generation_data_raw[max(0, e.pos-50):e.pos+50]}'")
except Exception as e:
print(f"MetaMan De-obfuscation Error: {e}")
return deobfuscated
def _extract_vae_info(self, prompt_data: dict) -> dict:
"""Extract VAE information from workflow"""
vae_info = {}
try:
for node_id, node_data in prompt_data.items():
if not isinstance(node_data, dict):
continue
class_type = node_data.get('class_type', '')
inputs = node_data.get('inputs', {})
# Check VAE loaders
if 'vae' in class_type.lower() and 'loader' in class_type.lower():
vae_name = inputs.get('vae_name', '')
if vae_name:
vae_info['vae_name'] = vae_name
print(f"MetaMan Universal: Found VAE: {vae_name}")
break
except Exception as e:
print(f"MetaMan Universal: Error extracting VAE info: {e}")
return vae_info
class MetaManUniversalNodeV2:
"""
Universal metadata management node for ComfyUI
Handles metadata conversion between all major AI image generation services
"""
# Supported services
SUPPORTED_SERVICES = [
"automatic1111", "comfyui", "civitai", "forge",
"tensor.ai", "seaart.ai", "leonardo.ai", "midjourney", "generic"
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"metadata_json": ("STRING", {"default": "", "multiline": True}),
"target_service": (cls.SUPPORTED_SERVICES, {"default": "automatic1111"}),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image_workflow", "json_workflow")
FUNCTION = "process_metadata"
CATEGORY = "MetaMan"
DESCRIPTION = "Universal metadata conversion and embedding for AI image generation platforms"
def __init__(self):
"""Initialize MetaMan with universal schema and service templates"""
self.schema_path = os.path.join(os.path.dirname(__file__), "templates", "universal_schema.yaml")
self.templates_dir = os.path.join(os.path.dirname(__file__), "templates", "services")
self.universal_schema = self._load_universal_schema()
self.service_templates = self._load_service_templates()
def _load_universal_schema(self) -> dict:
"""Load the universal metadata schema"""
try:
if os.path.exists(self.schema_path):
with open(self.schema_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
else:
return self._create_default_schema()
except Exception as e:
print(f"MetaMan: Error loading schema: {e}")
return self._create_default_schema()
def _create_default_schema(self) -> dict:
"""Create default universal schema"""
return {
"schema_version": "1.0.0",
"last_updated": datetime.now().isoformat(),
"fields": {
# Core generation parameters
"prompt": {
"type": "string",
"description": "Positive prompt text",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai", "leonardo.ai"],
"required": True
},
"negative_prompt": {
"type": "string",
"description": "Negative prompt text",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai"],
"required": False
},
"steps": {
"type": "integer",
"description": "Number of inference steps",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai", "leonardo.ai"],
"range": [1, 150]
},
"cfg_scale": {
"type": "float",
"description": "Classifier-free guidance scale",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai"],
"range": [1.0, 30.0]
},
"sampler": {
"type": "string",
"description": "Sampling method",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai"],
"mappings": {
"automatic1111": {
"Euler": "Euler",
"Euler a": "Euler a",
"DPM++ 2M Karras": "DPM++ 2M Karras",
"DPM++ SDE Karras": "DPM++ SDE Karras"
},
"comfyui": {
"Euler": "euler",
"Euler a": "euler_ancestral",
"DPM++ 2M Karras": "dpmpp_2m",
"DPM++ SDE Karras": "dpmpp_sde"
}
}
},
"scheduler": {
"type": "string",
"description": "Noise scheduler",
"supported_by": ["comfyui", "forge", "tensor.ai"],
"mappings": {
"comfyui": ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"],
"forge": ["normal", "karras", "exponential"]
}
},
"seed": {
"type": "integer",
"description": "Random seed",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai", "leonardo.ai"]
},
"width": {
"type": "integer",
"description": "Image width",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai", "leonardo.ai"]
},
"height": {
"type": "integer",
"description": "Image height",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai", "leonardo.ai"]
},
# Model information
"model_name": {
"type": "string",
"description": "Base model name",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai", "leonardo.ai"]
},
"model_hash": {
"type": "string",
"description": "Model file hash (SHA256 preferred)",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge"]
},
"model_download_url": {
"type": "string",
"description": "URL to download the model",
"supported_by": ["civitai", "huggingface"]
},
# LoRA information
"loras": {
"type": "array",
"description": "LoRA models used",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai"],
"item_schema": {
"name": "string",
"weight": "float",
"hash": "string",
"download_url": "string"
}
},
# Embeddings/Textual Inversions
"embeddings": {
"type": "array",
"description": "Textual inversions/embeddings used",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge"],
"item_schema": {
"name": "string",
"hash": "string",
"download_url": "string"
}
},
# Advanced parameters
"clip_skip": {
"type": "integer",
"description": "CLIP layers to skip",
"supported_by": ["automatic1111", "forge", "civitai"]
},
"eta": {
"type": "float",
"description": "Eta parameter for DDIM",
"supported_by": ["automatic1111", "forge"]
},
"denoising_strength": {
"type": "float",
"description": "Denoising strength for img2img",
"supported_by": ["automatic1111", "comfyui", "forge", "tensor.ai"]
},
# Service-specific fields
"comfyui_workflow": {
"type": "object",
"description": "Complete ComfyUI workflow JSON",
"supported_by": ["comfyui"]
},
"tensor_ai_style": {
"type": "string",
"description": "Tensor.AI style preset",
"supported_by": ["tensor.ai"]
},
"leonardo_preset": {
"type": "string",
"description": "Leonardo.AI model preset",
"supported_by": ["leonardo.ai"]
},
"midjourney_version": {
"type": "string",
"description": "Midjourney model version",
"supported_by": ["midjourney"]
},
# Metadata
"creation_time": {
"type": "string",
"description": "Image creation timestamp",
"supported_by": ["automatic1111", "comfyui", "civitai", "forge", "tensor.ai", "seaart.ai", "leonardo.ai"]
},
"source_service": {
"type": "string",
"description": "Original service that created the image",
"supported_by": ["metaman"]
},
"metaman_version": {
"type": "string",
"description": "MetaMan version used",
"supported_by": ["metaman"]
}
}
}
def _load_service_templates(self) -> dict:
"""Load service-specific output templates"""
templates = {}
if os.path.exists(self.templates_dir):
for service_file in os.listdir(self.templates_dir):
if service_file.endswith('.yaml'):
service_name = service_file.replace('.yaml', '')
try:
with open(os.path.join(self.templates_dir, service_file), 'r', encoding='utf-8') as f:
templates[service_name] = yaml.safe_load(f)
except Exception as e:
print(f"MetaMan: Error loading template for {service_name}: {e}")
return templates
def process_metadata(self, image, metadata_json, target_service):
"""
Main processing function: extract metadata, convert to target service, and embed in image
"""
try:
# Convert tensor to PIL Image
if isinstance(image, torch.Tensor):
img_tensor = image[0]
img_array = (img_tensor.cpu().numpy() * 255).astype('uint8')
pil_image = Image.fromarray(img_array)
else:
pil_image = image
# Check if metadata was provided from MetaManLoadImage node
source_metadata = {}
if metadata_json and metadata_json.strip():
try:
metadata_data = json.loads(metadata_json)
source_metadata = metadata_data.get("metadata", {})
print(f"MetaMan Debug: Using metadata from MetaManLoadImage: {list(source_metadata.keys())}")
print(f"MetaMan Debug: Source file: {metadata_data.get('source_file_path', 'unknown')}")
except Exception as e:
print(f"MetaMan Debug: Error parsing metadata_json: {e}")
return (image, json.dumps({"error": f"Invalid metadata_json format: {e}"}, indent=2))
else:
print(f"MetaMan Debug: No metadata_json provided - use MetaManLoadImage node for best results")
# If no metadata from load image node, try extracting from PIL image
if not source_metadata:
print(f"MetaMan Debug: Falling back to PIL image extraction...")
source_metadata = self._extract_source_metadata(pil_image)
# If still no metadata found, try to find and read the original file
if not source_metadata:
print(f"MetaMan Debug: No metadata in PIL image, attempting direct file reading...")
original_file_metadata = self._try_read_original_file(pil_image)
if original_file_metadata:
source_metadata = original_file_metadata
print(f"MetaMan Debug: Successfully read metadata from original file")
else:
print(f"MetaMan Debug: No metadata found anywhere - recommend using MetaManLoadImage node")
source_service = self._detect_source_service(source_metadata)
# Convert to universal format
universal_metadata = self._convert_to_universal(source_metadata, source_service)
# Add workflow extraction
workflow = self._extract_workflow(pil_image, source_service)
if workflow:
universal_metadata["workflow"] = workflow
# Generate dependencies using Civitai API
dependencies = self._generate_civitai_dependencies(universal_metadata)
if dependencies:
universal_metadata["dependencies"] = dependencies
# Convert to target service format
target_metadata = self._convert_from_universal(universal_metadata, target_service)
# Embed target service metadata in image
result_image = self._embed_metadata_in_image(pil_image, target_metadata, target_service)
# Prepare JSON output with all metadata
json_output = {
"source_service": source_service,
"target_service": target_service,
"universal_metadata": universal_metadata,
"target_metadata": target_metadata,
"conversion_timestamp": datetime.now().isoformat()
}
# Convert back to tensor if needed
if isinstance(image, torch.Tensor):
if isinstance(result_image, Image.Image):
import numpy as np
img_array = np.array(result_image).astype(np.float32) / 255.0
output_tensor = torch.from_numpy(img_array).unsqueeze(0)
return (output_tensor, json.dumps(json_output, indent=2))
return (result_image, json.dumps(json_output, indent=2))
except Exception as e:
error_msg = f"MetaMan Error: {str(e)}"
return (image, json.dumps({"error": error_msg}, indent=2))
def _embed_metadata_in_image(self, image: Image.Image, metadata: str, target_service: str) -> Image.Image:
"""
Embed target service metadata into image PNG chunks
"""
# Create new image copy
result_image = image.copy() if hasattr(image, 'copy') else image
# Prepare PNG info
png_info = PngInfo()
# Copy existing metadata
if hasattr(image, 'text') and image.text:
for key, value in image.text.items():
png_info.add_text(key, value)
# Add target service metadata
if target_service == "automatic1111" or target_service == "civitai":
# A1111/Civitai uses 'parameters' chunk
png_info.add_text("parameters", metadata)
elif target_service == "comfyui":
# ComfyUI uses 'workflow' and 'prompt' chunks
try:
# If metadata is JSON, try to extract workflow/prompt
if metadata.startswith('{'):
data = json.loads(metadata)
if 'comfyui_workflow' in data:
png_info.add_text("workflow", json.dumps(data['comfyui_workflow']))
if 'comfyui_prompt' in data:
png_info.add_text("prompt", json.dumps(data['comfyui_prompt']))
else:
# Fallback: add as workflow
png_info.add_text("workflow", metadata)
except:
png_info.add_text("workflow", metadata)
else:
# For other services, use service name as chunk name
chunk_name = target_service.replace('.', '_')
png_info.add_text(chunk_name, metadata)
# Add MetaMan universal chunk
metaman_data = {
'schema_version': '1.0.0',
'created_at': datetime.now().isoformat(),
'metaman_version': '1.0.0',
'target_service': target_service,
'original_metadata': metadata
}
png_info.add_text("meta", json.dumps(metaman_data, separators=(',', ':')))
# Save to temporary location to embed metadata
import tempfile
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file:
result_image.save(tmp_file, "PNG", pnginfo=png_info)
tmp_file.flush()
# Reload image with embedded metadata
result_image = Image.open(tmp_file.name)
result_image.load() # Ensure image is fully loaded
# Clean up temp file
os.unlink(tmp_file.name)
return result_image
def _extract_source_metadata(self, image) -> dict:
"""Extract metadata from image regardless of source format"""
metadata = {}
print(f"MetaMan Debug: Starting metadata extraction...")
print(f"MetaMan Debug: Image type: {type(image)}")
print(f"MetaMan Debug: Image mode: {getattr(image, 'mode', 'N/A')}")
print(f"MetaMan Debug: Image format: {getattr(image, 'format', 'N/A')}")
print(f"MetaMan Debug: Image size: {getattr(image, 'size', 'N/A')}")
# Check if image has filename attribute (from file loading)
if hasattr(image, 'filename'):
print(f"MetaMan Debug: Image filename: {image.filename}")
# Check PNG text chunks
if hasattr(image, 'text') and image.text:
print(f"MetaMan Debug: Found PNG text chunks: {list(image.text.keys())}")
for key, value in image.text.items():
print(f"MetaMan Debug: Chunk '{key}': {len(value)} characters")
# A1111/Civitai parameters format
if 'parameters' in image.text:
print(f"MetaMan Debug: Found 'parameters' chunk")
metadata.update(self._parse_a1111_parameters(image.text['parameters']))
# ComfyUI workflow format
if 'workflow' in image.text:
print(f"MetaMan Debug: Found 'workflow' chunk")
try:
workflow_data = json.loads(image.text['workflow'])
metadata['comfyui_workflow'] = workflow_data
print(f"MetaMan Debug: Successfully parsed workflow with {len(workflow_data.get('nodes', []))} nodes")
except Exception as e:
print(f"MetaMan Debug: Error parsing workflow: {e}")
# ComfyUI prompt format
if 'prompt' in image.text:
print(f"MetaMan Debug: Found 'prompt' chunk")
try:
prompt_data = json.loads(image.text['prompt'])
metadata['comfyui_prompt'] = prompt_data
print(f"MetaMan Debug: Successfully parsed prompt with {len(prompt_data)} nodes")
# Extract basic parameters from prompt data immediately
extracted_params = self._extract_params_from_comfyui_prompt(prompt_data)
metadata.update(extracted_params)
print(f"MetaMan Debug: Extracted params from prompt: {list(extracted_params.keys())}")
except Exception as e:
print(f"MetaMan Debug: Error parsing prompt: {e}")
# Custom "meta" chunk (our universal format)
if 'meta' in image.text:
print(f"MetaMan Debug: Found 'meta' chunk")
try:
meta_data = json.loads(image.text['meta'])
metadata.update(meta_data)
except Exception as e:
print(f"MetaMan Debug: Error parsing meta chunk: {e}")
else:
print(f"MetaMan Debug: No PNG text chunks found or image.text is empty")
print(f"MetaMan Debug: image.text value: {getattr(image, 'text', 'ATTRIBUTE_MISSING')}")
# Try alternative PNG info access
if hasattr(image, 'info'):
print(f"MetaMan Debug: Image.info keys: {list(image.info.keys())}")
for key, value in image.info.items():
if isinstance(value, str) and len(value) > 50:
print(f"MetaMan Debug: Info '{key}': {len(value)} characters")
# Try to parse as potential metadata
if key.lower() in ['parameters', 'workflow', 'prompt', 'meta']:
print(f"MetaMan Debug: Found potential metadata in info['{key}']")
if key == 'parameters':
metadata.update(self._parse_a1111_parameters(value))
elif key in ['workflow', 'prompt']:
try:
data = json.loads(value)
metadata[f'comfyui_{key}'] = data
if key == 'prompt':
extracted_params = self._extract_params_from_comfyui_prompt(data)
metadata.update(extracted_params)
except:
pass
# Check EXIF data
if hasattr(image, '_getexif') and image._getexif():
print(f"MetaMan Debug: Found EXIF data")
exif_data = image._getexif()
metadata.update(self._parse_exif_metadata(exif_data))
else:
print(f"MetaMan Debug: No EXIF data found")
print(f"MetaMan Debug: Final extracted metadata keys: {list(metadata.keys())}")
return metadata
def _parse_a1111_parameters(self, params_text: str) -> dict:
"""Parse A1111/Civitai parameters format"""
metadata = {}
lines = params_text.strip().split('\n')
if lines:
# First line is usually the positive prompt
metadata['prompt'] = lines[0].strip()
# Look for negative prompt
for i, line in enumerate(lines[1:], 1):
if line.startswith('Negative prompt:'):
metadata['negative_prompt'] = line.replace('Negative prompt:', '').strip()
continue
# Parse parameter line (usually the last line)
if ',' in line and ':' in line:
params = self._parse_parameter_line(line)
metadata.update(params)
return metadata
def _parse_parameter_line(self, line: str) -> dict:
"""Parse A1111 parameter line"""
params = {}
# Split by comma, but be careful of commas in quoted values
parts = []
current = ""
in_quotes = False
for char in line:
if char == '"' and (not current or current[-1] != '\\'):
in_quotes = not in_quotes
elif char == ',' and not in_quotes:
parts.append(current.strip())
current = ""
continue
current += char
if current.strip():
parts.append(current.strip())
# Parse each parameter
for part in parts:
if ':' in part:
key, value = part.split(':', 1)
key = key.strip().lower().replace(' ', '_')
value = value.strip()
# Convert known numeric values
if key in ['steps', 'width', 'height', 'seed', 'clip_skip']:
try:
params[key] = int(value)
except:
params[key] = value
elif key in ['cfg_scale', 'denoising_strength', 'eta']:
try:
params[key] = float(value)
except:
params[key] = value
else:
params[key] = value
return params
def _detect_source_service(self, metadata: dict) -> str:
"""Detect which service generated the image based on metadata"""
print(f"MetaMan Debug: Detecting service for metadata keys: {list(metadata.keys())}")
# ComfyUI indicators (check multiple possible indicators)
comfyui_indicators = [
'comfyui_workflow' in metadata,
'comfyui_prompt' in metadata,
'workflow' in metadata, # Raw workflow chunk
'prompt' in metadata and isinstance(metadata.get('prompt'), dict), # ComfyUI prompt dict
any(key.startswith('comfyui_') for key in metadata.keys()),
# Check if we have typical ComfyUI node data
any(isinstance(value, dict) and 'class_type' in str(value) for value in metadata.values())
]
if any(comfyui_indicators):
print(f"MetaMan Debug: Detected ComfyUI service (indicators: {comfyui_indicators})")
return 'comfyui'
# A1111/Civitai indicators
elif 'model_hash' in metadata and 'sampler' in metadata:
print(f"MetaMan Debug: Detected A1111/Civitai service")
return 'automatic1111'
# Other services
elif 'tensor_ai_style' in metadata:
print(f"MetaMan Debug: Detected Tensor.AI service")
return 'tensor.ai'
elif 'leonardo_preset' in metadata:
print(f"MetaMan Debug: Detected Leonardo.AI service")
return 'leonardo.ai'
elif 'midjourney_version' in metadata:
print(f"MetaMan Debug: Detected Midjourney service")
return 'midjourney'
# If we have basic generation params, try to infer
elif 'prompt' in metadata and isinstance(metadata['prompt'], str):
# Check for A1111-style parameters
if any(key in metadata for key in ['steps', 'cfg_scale', 'seed']):
print(f"MetaMan Debug: Detected generic A1111-style metadata")
return 'automatic1111'
print(f"MetaMan Debug: Could not detect service, using generic")
return 'generic'
def _convert_to_universal(self, source_metadata: dict, source_service: str) -> dict:
"""Convert source metadata to universal format"""
print(f"MetaMan Debug: Converting to universal format from {source_service}")
print(f"MetaMan Debug: Source metadata keys: {list(source_metadata.keys())}")
universal = {
"schema_version": self.universal_schema["schema_version"],
"source_service": source_service,
"creation_time": datetime.now().isoformat(),
"metaman_version": "1.0.0",
"metadata": {}
}
# First, copy all source metadata directly
for key, value in source_metadata.items():
universal["metadata"][key] = value
print(f"MetaMan Debug: Copied direct field {key}: {type(value)}")
# Then map source fields to universal schema (for compatibility)
mapped_count = 0
for field_name, field_config in self.universal_schema["fields"].items():
if source_service in field_config.get("supported_by", []):
# Direct mapping
if field_name in source_metadata:
if field_name not in universal["metadata"]:
universal["metadata"][field_name] = source_metadata[field_name]
mapped_count += 1
print(f"MetaMan Debug: Mapped schema field {field_name}")
# Handle mappings
elif "mappings" in field_config and source_service in field_config["mappings"]:
source_value = source_metadata.get(field_name)
if source_value:
mapping = field_config["mappings"][source_service]
if isinstance(mapping, dict):
# Reverse lookup for source->universal conversion
for universal_val, source_val in mapping.items():
if source_val == source_value:
universal["metadata"][field_name] = universal_val
mapped_count += 1
print(f"MetaMan Debug: Mapped via schema {field_name}: {source_val} -> {universal_val}")
break
else:
universal["metadata"][field_name] = source_value
mapped_count += 1
print(f"MetaMan Debug: Mapped direct {field_name}: {source_value}")
else:
universal["metadata"][field_name] = source_value
mapped_count += 1
print(f"MetaMan Debug: Mapped simple {field_name}: {source_value}")
print(f"MetaMan Debug: Universal conversion complete. {len(universal['metadata'])} total fields, {mapped_count} schema-mapped")
print(f"MetaMan Debug: Final universal metadata keys: {list(universal['metadata'].keys())}")
return universal
def _convert_from_universal(self, universal_metadata: dict, target_service: str) -> str:
"""Convert universal metadata to target service format"""
if target_service not in self.SUPPORTED_SERVICES:
return f"Unsupported target service: {target_service}"
# Use service template if available
if target_service in self.service_templates:
return self._apply_service_template(universal_metadata, target_service)
else:
return self._generate_default_format(universal_metadata, target_service)
def _apply_service_template(self, universal_metadata: dict, target_service: str) -> str:
"""Apply service-specific template for output formatting"""
template = self.service_templates[target_service]
metadata = universal_metadata.get("metadata", {})
if target_service == "automatic1111":
return self._format_a1111_output(metadata, template)
elif target_service == "comfyui":
return self._format_comfyui_output(metadata, template)
else:
return json.dumps(metadata, indent=2)
def _format_a1111_output(self, metadata: dict, template: dict) -> str:
"""Format metadata for A1111 compatibility"""
print(f"MetaMan Debug: Formatting A1111 output from metadata: {list(metadata.keys())}")
lines = []
# Positive prompt
if 'prompt' in metadata:
lines.append(metadata['prompt'])
print(f"MetaMan Debug: Added prompt: {metadata['prompt'][:100]}...")
else:
print(f"MetaMan Debug: No prompt found in metadata")
# Negative prompt
if 'negative_prompt' in metadata:
lines.append(f"Negative prompt: {metadata['negative_prompt']}")
print(f"MetaMan Debug: Added negative prompt: {metadata['negative_prompt'][:100]}...")
# Parameters line
params = []
param_order = template.get("parameter_order", [
"steps", "sampler", "cfg_scale", "seed", "width", "height",
"model_hash", "clip_skip", "denoising_strength"
])
print(f"MetaMan Debug: Processing parameters in order: {param_order}")
for param in param_order:
if param in metadata and metadata[param] is not None:
print(f"MetaMan Debug: Found parameter {param}: {metadata[param]}")
if param == "cfg_scale":
params.append(f"CFG scale: {metadata[param]}")
elif param == "model_hash":
params.append(f"Model hash: {metadata[param]}")
elif param == "clip_skip":
params.append(f"Clip skip: {metadata[param]}")
elif param == "denoising_strength":
params.append(f"Denoising strength: {metadata[param]}")
elif param == "width" and "height" in metadata:
params.append(f"Size: {metadata['width']}x{metadata['height']}")
print(f"MetaMan Debug: Added size: {metadata['width']}x{metadata['height']}")
elif param != "height": # Skip height since it's handled with width
params.append(f"{param.title()}: {metadata[param]}")
else:
print(f"MetaMan Debug: Parameter {param} not found or is None")
if params:
param_line = ", ".join(params)
lines.append(param_line)
print(f"MetaMan Debug: Added parameters line: {param_line}")
else:
print(f"MetaMan Debug: No parameters to add")
result = "\n".join(lines)
print(f"MetaMan Debug: Final A1111 output ({len(result)} chars): {result[:200]}...")
return result
def _format_comfyui_output(self, metadata: dict, template: dict) -> str:
"""Format metadata for ComfyUI compatibility"""
# For ComfyUI, we primarily return the workflow if available
if 'comfyui_workflow' in metadata:
return json.dumps(metadata['comfyui_workflow'], indent=2)
else:
# Generate basic ComfyUI-compatible metadata
comfyui_meta = {}
for field, value in metadata.items():
if field in self.universal_schema["fields"]:
field_config = self.universal_schema["fields"][field]
if "comfyui" in field_config.get("supported_by", []):
# Apply mappings if available
if "mappings" in field_config and "comfyui" in field_config["mappings"]:
mapping = field_config["mappings"]["comfyui"]
if isinstance(mapping, dict) and value in mapping:
comfyui_meta[field] = mapping[value]
else:
comfyui_meta[field] = value
else:
comfyui_meta[field] = value
return json.dumps(comfyui_meta, indent=2)
def _generate_default_format(self, universal_metadata: dict, target_service: str) -> str:
"""Generate default format for services without specific templates"""
metadata = universal_metadata.get("metadata", {})
filtered = {}
# Only include fields supported by target service
for field, value in metadata.items():
if field in self.universal_schema["fields"]:
field_config = self.universal_schema["fields"][field]
if target_service in field_config.get("supported_by", []):
filtered[field] = value
return json.dumps(filtered, indent=2)
def _generate_civitai_dependencies(self, universal_metadata: dict) -> list[dict]:
"""
Generate model dependencies using Civitai API
"""
dependencies = []
metadata = universal_metadata.get("metadata", {})
# Main model
if 'model_name' in metadata and metadata['model_name']:
dep = self._search_civitai_model(metadata['model_name'], 'checkpoint', metadata.get('model_hash', ''))
if dep:
dependencies.append(dep)
# LoRAs
if 'loras' in metadata and isinstance(metadata['loras'], list):
for lora in metadata['loras']:
if isinstance(lora, dict) and lora.get('name'):
dep = self._search_civitai_model(lora['name'], 'lora', lora.get('hash', ''), lora.get('weight', 1.0))
if dep:
dependencies.append(dep)
# Embeddings
if 'embeddings' in metadata and isinstance(metadata['embeddings'], list):
for embedding in metadata['embeddings']:
if isinstance(embedding, dict) and embedding.get('name'):
dep = self._search_civitai_model(embedding['name'], 'embedding', embedding.get('hash', ''))
if dep:
dependencies.append(dep)
return dependencies
def _search_civitai_model(self, name: str, model_type: str, hash_value: str = '', weight: float = None) -> Optional[dict]:
"""
Search for a model on Civitai API
"""
try:
import requests
import time
# Rate limiting
time.sleep(0.5)
# Search by name first
search_url = f"https://civitai.com/api/v1/models?query={name}&limit=5"
response = requests.get(search_url, timeout=10)
if response.status_code != 200:
return None
data = response.json()
items = data.get('items', [])
if not items:
return None
# Find best match
best_match = None
for item in items:
# Check if name matches (case insensitive)
if name.lower() in item.get('name', '').lower():
# If we have a hash, try to match it
if hash_value:
for version in item.get('modelVersions', []):
for file in version.get('files', []):
if file.get('hashes', {}).get('SHA256', '').startswith(hash_value[:10]):
best_match = item
break
if best_match:
break
else:
best_match = item
break
if not best_match:
best_match = items[0] # Fallback to first result
# Extract information
model_info = {
'type': model_type,
'name': name,
'civitai_id': best_match.get('id'),
'civitai_name': best_match.get('name'),
'creator': best_match.get('creator', {}).get('username', ''),
'download_url': '',
'hash': hash_value,
'confidence': 0.8 if hash_value else 0.6
}
if weight is not None:
model_info['weight'] = weight
# Get download URL from latest version
versions = best_match.get('modelVersions', [])
if versions:
latest_version = versions[0]
files = latest_version.get('files', [])
if files:
model_info['download_url'] = files[0].get('downloadUrl', '')
model_info['civitai_version_id'] = latest_version.get('id')
return model_info
except Exception as e:
print(f"MetaMan: Error searching Civitai for {name}: {e}")
return None
def _extract_workflow(self, image, source_service: str) -> Optional[dict]:
"""Extract workflow information if available"""
if hasattr(image, 'text') and image.text:
if 'workflow' in image.text:
try:
return json.loads(image.text['workflow'])
except:
pass
return None
def _parse_exif_metadata(self, exif_data: dict) -> dict:
"""Parse EXIF data for AI generation information"""
# Implementation for EXIF parsing
return {}
def _extract_params_from_comfyui_prompt(self, prompt_data: dict) -> dict:
"""Extract generation parameters from ComfyUI prompt data"""
params = {}
print(f"MetaMan Debug: Extracting params from ComfyUI prompt with {len(prompt_data)} nodes")
try:
for node_id, node_data in prompt_data.items():
if not isinstance(node_data, dict):
continue
class_type = node_data.get('class_type', '')
inputs = node_data.get('inputs', {})
print(f"MetaMan Debug: Processing node {node_id} of type {class_type}")
# Extract from KSampler nodes
if class_type == 'KSampler':
if 'steps' in inputs:
params['steps'] = inputs['steps']
print(f"MetaMan Debug: Found steps: {inputs['steps']}")
if 'cfg' in inputs:
params['cfg_scale'] = inputs['cfg']
print(f"MetaMan Debug: Found CFG: {inputs['cfg']}")
if 'sampler_name' in inputs:
params['sampler'] = inputs['sampler_name']
print(f"MetaMan Debug: Found sampler: {inputs['sampler_name']}")
if 'scheduler' in inputs:
params['scheduler'] = inputs['scheduler']
print(f"MetaMan Debug: Found scheduler: {inputs['scheduler']}")
if 'seed' in inputs:
params['seed'] = inputs['seed']
print(f"MetaMan Debug: Found seed: {inputs['seed']}")
if 'denoise' in inputs:
params['denoising_strength'] = inputs['denoise']
print(f"MetaMan Debug: Found denoise: {inputs['denoise']}")
# Extract from CheckpointLoaderSimple
elif class_type == 'CheckpointLoaderSimple':
if 'ckpt_name' in inputs:
params['model_name'] = inputs['ckpt_name']
print(f"MetaMan Debug: Found model: {inputs['ckpt_name']}")
# Extract from CLIPTextEncode (prompts)
elif class_type == 'CLIPTextEncode':
if 'text' in inputs:
# First positive prompt we find
if 'prompt' not in params and inputs['text'].strip():
params['prompt'] = inputs['text']
print(f"MetaMan Debug: Found prompt: {inputs['text'][:100]}...")
# If we already have a prompt, this might be negative
elif 'negative_prompt' not in params and inputs['text'].strip():
# Simple heuristic: if it contains common negative words
negative_indicators = ['worst', 'low quality', 'blurry', 'bad', 'ugly', 'deformed']
if any(indicator in inputs['text'].lower() for indicator in negative_indicators):
params['negative_prompt'] = inputs['text']
print(f"MetaMan Debug: Found negative prompt: {inputs['text'][:100]}...")
# Extract from EmptyLatentImage (dimensions)
elif class_type == 'EmptyLatentImage':
if 'width' in inputs:
params['width'] = inputs['width']
print(f"MetaMan Debug: Found width: {inputs['width']}")
if 'height' in inputs:
params['height'] = inputs['height']
print(f"MetaMan Debug: Found height: {inputs['height']}")
# Extract from LoraLoader nodes
elif class_type == 'LoraLoader':
if 'lora_name' in inputs and 'strength_model' in inputs:
if 'loras' not in params:
params['loras'] = []
lora_info = {
'name': inputs['lora_name'],
'weight': inputs['strength_model']
}
if 'strength_clip' in inputs:
lora_info['clip_weight'] = inputs['strength_clip']
params['loras'].append(lora_info)
print(f"MetaMan Debug: Found LoRA: {inputs['lora_name']} @ {inputs['strength_model']}")
except Exception as e:
print(f"MetaMan Debug: Error extracting ComfyUI params: {e}")
print(f"MetaMan Debug: Final extracted params: {list(params.keys())}")
return params
def _try_read_original_file(self, pil_image) -> dict:
"""Attempt to find and read the original PNG file with metadata"""
metadata = {}
try:
import glob
# Common ComfyUI directories to search
search_paths = [
"/Users/pxl8d/Art/ComfyUI/input/*.png",
"/Users/pxl8d/Art/ComfyUI/output/*.png",
"/Users/pxl8d/Art/ComfyUI/temp/*.png"
]
# Get image dimensions for matching
img_size = pil_image.size if hasattr(pil_image, 'size') else None
print(f"MetaMan Debug: Looking for original file with size {img_size}")
# Search for PNG files in common locations
candidates = []
for search_path in search_paths:
try:
files = glob.glob(search_path)
candidates.extend(files)
print(f"MetaMan Debug: Found {len(files)} PNG files in {search_path}")
except Exception as e:
print(f"MetaMan Debug: Error searching {search_path}: {e}")
# Sort by modification time (newest first)
candidates.sort(key=lambda x: os.path.getmtime(x) if os.path.exists(x) else 0, reverse=True)
print(f"MetaMan Debug: Checking {len(candidates)} candidate files...")
# Try to find a matching file
for file_path in candidates[:10]: # Check up to 10 most recent files
try:
print(f"MetaMan Debug: Checking file: {file_path}")
# Open file directly with PIL to preserve metadata
with Image.open(file_path) as test_image:
# Check if dimensions match
if img_size and test_image.size != img_size:
print(f"MetaMan Debug: Size mismatch: {test_image.size} vs {img_size}")
continue
print(f"MetaMan Debug: Size match found: {test_image.size}")
# Check for PNG text chunks
if hasattr(test_image, 'text') and test_image.text:
print(f"MetaMan Debug: File has PNG text chunks: {list(test_image.text.keys())}")
# Extract metadata from this file
file_metadata = self._extract_metadata_from_file_image(test_image)
if file_metadata:
print(f"MetaMan Debug: Successfully extracted metadata from {file_path}")
return file_metadata
else:
print(f"MetaMan Debug: File has no PNG text chunks")
except Exception as e:
print(f"MetaMan Debug: Error reading {file_path}: {e}")
continue
print(f"MetaMan Debug: No matching file with metadata found")
except Exception as e:
print(f"MetaMan Debug: Error in file search: {e}")
return metadata
def _extract_metadata_from_file_image(self, file_image) -> dict:
"""Extract metadata from a file-loaded PIL image (preserves PNG chunks)"""
metadata = {}
try:
# Check PNG text chunks
if hasattr(file_image, 'text') and file_image.text:
print(f"MetaMan Debug: File image has text chunks: {list(file_image.text.keys())}")
# A1111/Civitai parameters format
if 'parameters' in file_image.text:
print(f"MetaMan Debug: Found 'parameters' chunk in file")
metadata.update(self._parse_a1111_parameters(file_image.text['parameters']))
# ComfyUI workflow format
if 'workflow' in file_image.text:
print(f"MetaMan Debug: Found 'workflow' chunk in file")
try:
workflow_data = json.loads(file_image.text['workflow'])
metadata['comfyui_workflow'] = workflow_data
print(f"MetaMan Debug: Successfully parsed workflow with {len(workflow_data.get('nodes', []))} nodes")
except Exception as e:
print(f"MetaMan Debug: Error parsing workflow from file: {e}")
# ComfyUI prompt format
if 'prompt' in file_image.text:
print(f"MetaMan Debug: Found 'prompt' chunk in file")
try:
prompt_data = json.loads(file_image.text['prompt'])
metadata['comfyui_prompt'] = prompt_data
print(f"MetaMan Debug: Successfully parsed prompt with {len(prompt_data)} nodes")
# Extract basic parameters from prompt data
extracted_params = self._extract_params_from_comfyui_prompt(prompt_data)
metadata.update(extracted_params)
print(f"MetaMan Debug: Extracted params from file prompt: {list(extracted_params.keys())}")
except Exception as e:
print(f"MetaMan Debug: Error parsing prompt from file: {e}")
# Custom "meta" chunk
if 'meta' in file_image.text:
print(f"MetaMan Debug: Found 'meta' chunk in file")
try:
meta_data = json.loads(file_image.text['meta'])
metadata.update(meta_data)
except Exception as e:
print(f"MetaMan Debug: Error parsing meta chunk from file: {e}")
except Exception as e:
print(f"MetaMan Debug: Error extracting metadata from file image: {e}")
print(f"MetaMan Debug: File metadata extraction complete. Keys: {list(metadata.keys())}")
return metadata
# Node mappings for ComfyUI
NODE_CLASS_MAPPINGS = {
"MetaManLoadAndConvert": MetaManLoadAndConvert,
"MetaManExtractComponents": MetaManExtractComponents,
"MetaManEmbedAndSave": MetaManEmbedAndSave,
"MetaManUniversalNodeV2": MetaManUniversalNodeV2,
"MetaManLoadImage": MetaManLoadImage
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MetaManLoadAndConvert": "MetaMan Load & Convert",
"MetaManExtractComponents": "MetaMan Extract Components",
"MetaManEmbedAndSave": "MetaMan Embed & Save",
"MetaManUniversalNodeV2": "MetaMan Universal V2",
"MetaManLoadImage": "MetaMan Load Image"
}