Files
MatthewClayHarrison-ComfyUI…/metaman.py
T

2190 lines
98 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"""
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:
"""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", {"default": "", "multiline": 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", "loras", "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 = str(metadata.get('model_name', ''))
denoising_strength = float(metadata.get('denoising_strength', 1.0))
# Format LoRAs as a readable string
loras_str = ""
if 'loras' in metadata and isinstance(metadata['loras'], list):
lora_parts = []
for lora in metadata['loras']:
if isinstance(lora, dict):
name = lora.get('name', '')
weight = lora.get('weight', 1.0)
lora_parts.append(f"{name}:{weight}")
loras_str = ", ".join(lora_parts)
print(f"MetaMan Extract Components: Successfully extracted {len([x for x in [positive_prompt, negative_prompt, model_name] if x])} text components")
return (positive_prompt, negative_prompt, steps, cfg_scale, sampler, scheduler, seed, width, height, model_name, loras_str, 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 with metadata
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"metadata_text": ("STRING", {"default": "", "multiline": True}),
},
"optional": {
"filename_prefix": ("STRING", {"default": "MetaMan_converted"})
}
}
CATEGORY = "MetaMan"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("final_image",)
FUNCTION = "embed_and_save"
DESCRIPTION = "Embed metadata into image and save as PNG"
def embed_and_save(self, image, metadata_text, filename_prefix="MetaMan_converted"):
"""
Embed metadata into image and return final image with metadata
"""
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
# Embed metadata in image
result_image = self._embed_metadata_in_image(pil_image, metadata_text)
# Save to ComfyUI output directory
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)
# Save with metadata
png_info = PngInfo()
png_info.add_text("parameters", metadata_text)
png_info.add_text("metaman_converted", datetime.now().isoformat())
result_image.save(filepath, "PNG", pnginfo=png_info)
print(f"MetaMan Embed & Save: Saved image with metadata to {filepath}")
# Convert back to tensor
if isinstance(image, torch.Tensor):
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,)
return (result_image,)
except Exception as e:
print(f"MetaMan Embed & Save Error: {e}")
return (image,)
def _embed_metadata_in_image(self, image: Image.Image, metadata_text: str) -> Image.Image:
"""Embed metadata into image PNG chunks"""
# Create new image copy
result_image = image.copy() if hasattr(image, 'copy') else image
# This is handled in the save process with PngInfo
return result_image
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)
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)
# Phase 5: 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]}")
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'],
'model_name': ['ckpt_name', 'model_name', 'checkpoint_name', 'model', 'checkpoint'],
'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
def _find_text_fields(self, inputs: dict) -> list:
"""Find all text fields in node inputs"""
text_fields = []
text_field_names = [
'text', 'prompt', 'positive', 'negative',
'wildcard_text', 'populated_text', 'formatted_text',
'content', 'description', 'caption'
]
for field_name, value in inputs.items():
if isinstance(value, str) and len(value.strip()) > 0:
# Direct text fields
if any(text_name in field_name.lower() for text_name in text_field_names):
text_fields.append((field_name, value))
# Check for any other string fields that might contain prompts
elif len(value) > 50: # Likely to be prompt text
text_fields.append((field_name, value))
return text_fields
def _analyze_text_content(self, text: str, field_name: str) -> str:
"""Analyze text content to determine if it's positive, negative, or general"""
text_lower = text.lower()
field_lower = field_name.lower()
# Field name indicators
if 'negative' in field_lower or 'bad' in field_lower:
return 'negative'
elif 'positive' in field_lower or 'prompt' in field_lower:
return 'positive'
# Content analysis
negative_indicators = [
'worst', 'low quality', 'blurry', 'bad', 'ugly', 'deformed',
'distorted', 'mutation', 'error', 'artifact', 'disfigured',
'extra limbs', 'missing', 'cropped', 'watermark', 'text'
]
positive_indicators = [
'masterpiece', 'best quality', 'high resolution', 'detailed',
'beautiful', 'professional', 'sharp focus', 'cinematic',
'score_9', 'score_8', 'photorealistic', 'ultra'
]
negative_count = sum(1 for indicator in negative_indicators if indicator in text_lower)
positive_count = sum(1 for indicator in positive_indicators if indicator in text_lower)
if negative_count > positive_count and negative_count > 2:
return 'negative'
elif positive_count > 0 or len(text) > 100: # Longer text usually positive
return 'positive'
else:
return 'general'
def _score_text_relevance(self, text: str, field_name: str, category: str) -> float:
"""Score text relevance for prompt selection"""
score = 0.0
# Field name scoring
field_lower = field_name.lower()
if 'populated' in field_lower or 'formatted' in field_lower:
score += 10.0 # Processed text is usually better
elif 'text' in field_lower or 'prompt' in field_lower:
score += 5.0
# Content scoring
if category == 'positive':
score += len(text) * 0.01 # Longer positive prompts often better
if '<lora:' in text:
score += 5.0 # LoRAs indicate main prompt
elif category == 'negative':
score += 3.0 # Give negative prompts moderate priority
return score
def _select_best_prompts(self, text_candidates: dict, params: dict):
"""Select the best positive and negative prompts"""
# Sort candidates by score
for category in text_candidates:
text_candidates[category].sort(key=lambda x: x['score'], reverse=True)
# Select best positive prompt
if text_candidates['positive']:
best_positive = text_candidates['positive'][0]
params['prompt'] = best_positive['text']
print(f"MetaMan Debug: Selected positive from {best_positive['node']}.{best_positive['field']} (score: {best_positive['score']:.1f})")
elif text_candidates['general']:
# Fallback to general text
best_general = text_candidates['general'][0]
params['prompt'] = best_general['text']
print(f"MetaMan Debug: Fallback positive from {best_general['node']}.{best_general['field']}")
# Select best negative prompt
if text_candidates['negative']:
best_negative = text_candidates['negative'][0]
params['negative_prompt'] = best_negative['text']
print(f"MetaMan Debug: Selected negative from {best_negative['node']}.{best_negative['field']} (score: {best_negative['score']:.1f})")
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"""
score = 0.0
text = candidate['text']
text_lower = text.lower()
field_name = candidate['field_name'].lower()
# Field name indicators (strong signals)
if 'positive' in field_name or 'prompt' in field_name:
score += 3.0
if 'populated' in field_name or 'formatted' in field_name:
score += 2.0 # Processed text usually main prompt
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 (strong indicator of main prompt)
if '<lora:' in text:
lora_count = text.count('<lora:')
score += lora_count * 1.0
print(f"MetaMan Universal: Found {lora_count} LoRAs in {candidate['source']} (+{lora_count} score)")
# Length scoring (longer text often positive)
if len(text) > 200:
score += 2.0
elif len(text) > 100:
score += 1.0
elif len(text) > 50:
score += 0.5
# Negative indicators (reduce score)
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 -= 2.0 # Likely negative prompt
return max(0.0, score)
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_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
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"
}