Files
Flibens-comfyui-image-browser/__init__.py
T
Flibens 7fa5a41fa3 fix: support default SaveImage metadata parsing
- Parse ComfyUI API prompt metadata from default SaveImage nodes
- Resolve linked prompt, sampler, size, model, and LoRA fields
- Include workflow nodes stored inside subgraph definitions
- Suppress duplicate negative prompts when no real negative prompt exists
- Add regression coverage for default SaveImage metadata cases
2026-07-04 20:35:14 -06:00

1344 lines
54 KiB
Python

"""
ComfyUI Image Browser - Fixed Metadata Version
"""
import os
import json
import re
import shutil
import subprocess
from pathlib import Path
from aiohttp import web
import server
import folder_paths
try:
from PIL import Image, ExifTags
except ImportError:
Image = None
# --- Constants ---
WEB_DIRECTORY = "./web"
__version__ = "1.5.0"
MODULE_DIR = Path(__file__).parent
LEGACY_ADDITIONAL_FOLDERS_FILE = MODULE_DIR / "folders.json"
LEGACY_FAVORITES_FILE = MODULE_DIR / "favorites.json"
def _get_persistent_state_dir():
candidates = []
get_user_directory = getattr(folder_paths, "get_user_directory", None)
if callable(get_user_directory):
try:
candidates.append(Path(get_user_directory()))
except Exception:
pass
user_directory = getattr(folder_paths, "user_directory", None)
if user_directory:
try:
candidates.append(Path(user_directory))
except Exception:
pass
base_path = getattr(folder_paths, "base_path", None)
if base_path:
try:
candidates.append(Path(base_path) / "user")
except Exception:
pass
for candidate in candidates:
try:
state_dir = candidate.expanduser().resolve() / "comfyui-image-browser"
state_dir.mkdir(parents=True, exist_ok=True)
return state_dir
except Exception:
continue
fallback_dir = MODULE_DIR / ".state"
fallback_dir.mkdir(parents=True, exist_ok=True)
return fallback_dir
STATE_DIRECTORY = _get_persistent_state_dir()
LEGACY_STATE_DIRECTORY = STATE_DIRECTORY.parent / "gemini-image-browser"
ADDITIONAL_FOLDERS_FILE = STATE_DIRECTORY / "folders.json"
FAVORITES_FILE = STATE_DIRECTORY / "favorites.json"
# --- Helper Functions ---
def _is_relative_to(path_obj, base_obj):
try:
path_obj.relative_to(base_obj)
return True
except ValueError:
return False
def _get_allowed_roots():
roots = []
for getter_name in ["get_output_directory", "get_input_directory", "get_temp_directory"]:
getter = getattr(folder_paths, getter_name, None)
if not callable(getter):
continue
try:
root = Path(getter()).expanduser().resolve()
roots.append(root)
except Exception:
continue
return roots
def _is_under_allowed_roots(path_obj):
for root in _get_allowed_roots():
if _is_relative_to(path_obj, root):
return True
return False
def _build_folder_map():
output_dir = Path(folder_paths.get_output_directory()).expanduser().resolve()
folder_map = {"default": output_dir}
for folder in load_json(ADDITIONAL_FOLDERS_FILE):
folder_id = folder.get("id")
folder_path = folder.get("path")
if not folder_id or not folder_path:
continue
try:
resolved = Path(folder_path).expanduser().resolve()
except Exception:
continue
if resolved.exists() and resolved.is_dir():
folder_map[folder_id] = resolved
return folder_map
def _resolve_folder_path(folder_id):
folder_map = _build_folder_map()
return folder_map.get(folder_id)
def _resolve_file_from_request(base_dir, request_path):
if not isinstance(request_path, str) or not request_path.strip():
return None
requested = Path(request_path)
if requested.is_absolute() or requested.drive:
return None
try:
full_path = (base_dir / requested).resolve()
except Exception:
return None
if not _is_relative_to(full_path, base_dir):
return None
return full_path
def load_json(file_path):
if not file_path.exists():
return []
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f"Error reading {file_path}: {e}")
return []
# Treat empty/whitespace files as empty arrays and auto-heal to valid JSON.
if not content.strip():
try:
save_json([], file_path)
except Exception:
pass
return []
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, list) else []
except Exception as e:
# Auto-heal invalid JSON to prevent repeated parse spam in logs.
print(f"Error loading {file_path}: {e} (resetting to [])")
try:
save_json([], file_path)
except Exception:
pass
return []
def save_json(data, file_path):
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
def _initialize_persistent_state():
migrations = [
(LEGACY_STATE_DIRECTORY / "folders.json", ADDITIONAL_FOLDERS_FILE),
(LEGACY_STATE_DIRECTORY / "favorites.json", FAVORITES_FILE),
(LEGACY_ADDITIONAL_FOLDERS_FILE, ADDITIONAL_FOLDERS_FILE),
(LEGACY_FAVORITES_FILE, FAVORITES_FILE),
]
for legacy_file, persistent_file in migrations:
try:
persistent_file.parent.mkdir(parents=True, exist_ok=True)
if persistent_file.exists():
continue
if legacy_file.exists():
shutil.copy2(legacy_file, persistent_file)
print(f"[ComfyUI Image Browser] Migrated {legacy_file.name} -> {persistent_file}")
else:
save_json([], persistent_file)
except Exception as e:
print(f"[ComfyUI Image Browser] Failed to initialize {persistent_file}: {e}")
def _safe_str(value):
if value is None:
return ""
if isinstance(value, bytes):
try:
return value.decode("utf-8", errors="ignore")
except Exception:
return f"<{len(value)} bytes>"
return str(value)
def extract_metadata(image_path):
"""Extract metadata from image (PNG/JPEG/WEBP/etc)"""
try:
if not Image or not image_path.exists():
return {}
with Image.open(image_path) as img:
metadata = {}
# Extract PNG text chunks (ComfyUI stores workflow here)
if hasattr(img, 'text'):
for key, value in img.text.items():
metadata[key] = value
# Also check PNG info dict
if hasattr(img, 'info'):
for key, value in img.info.items():
if key not in metadata:
metadata[key] = value
# EXIF (JPEG/WEBP/etc)
exif_data = {}
try:
if hasattr(img, "_getexif") and img._getexif():
for tag, val in img._getexif().items():
name = ExifTags.TAGS.get(tag, str(tag)) if Image else str(tag)
exif_data[name] = val
except Exception:
pass
if exif_data:
metadata["EXIF"] = exif_data
# XMP (if present)
if "XML:com.adobe.xmp" in metadata:
metadata["XMP"] = metadata.get("XML:com.adobe.xmp")
elif "xmp" in metadata:
metadata["XMP"] = metadata.get("xmp")
# If ICC profile exists, include a summary only
if "icc_profile" in metadata:
icc = metadata.get("icc_profile")
metadata["ICC Profile"] = f"<{len(icc)} bytes>" if isinstance(icc, (bytes, bytearray)) else _safe_str(icc)
# Try to decode 'prompt' and 'workflow' if they are JSON strings
for key in ['prompt', 'workflow']:
if key in metadata and isinstance(metadata[key], str):
try:
metadata[key] = json.loads(metadata[key])
except:
pass
return metadata
except Exception as e:
print(f"Error extracting metadata from {image_path}: {e}")
return {}
def _validate_and_get_workflow(json_string):
try:
data = json.loads(json_string)
workflow_data = data.get('workflow', data.get('prompt', data))
if isinstance(workflow_data, dict):
if 'nodes' in workflow_data:
return workflow_data, 'ui'
is_api = False
for _, value in workflow_data.items():
if isinstance(value, dict) and 'class_type' in value:
is_api = True
break
if is_api:
return workflow_data, 'api'
except Exception:
pass
return None, None
def _scan_bytes_for_workflow(content_bytes):
"""
Yield valid JSON object strings found in a binary stream by brace matching.
"""
try:
stream_str = content_bytes.decode('utf-8', errors='ignore')
except Exception:
return
start_pos = 0
while True:
first_brace = stream_str.find('{', start_pos)
if first_brace == -1:
break
open_braces = 0
start_index = first_brace
for i in range(start_index, len(stream_str)):
char = stream_str[i]
if char == '{':
open_braces += 1
elif char == '}':
open_braces -= 1
if open_braces == 0:
candidate = stream_str[start_index:i + 1]
try:
json.loads(candidate)
yield candidate
except Exception:
pass
start_pos = i + 1
break
else:
break
def extract_workflow_from_file(file_path):
"""
Extract workflow payload from image/video/audio files.
Returns (workflow_dict_or_none, 'ui'|'api'|None).
"""
found = {}
def analyze_json(json_str):
wf, wf_type = _validate_and_get_workflow(json_str)
if wf and wf_type and wf_type not in found:
found[wf_type] = wf
ext_lower = str(file_path).lower()
is_media = ext_lower.endswith(('.mp4', '.mkv', '.webm', '.mov', '.avi', '.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac'))
# Video/audio metadata tags via ffprobe (primary path for many encoded files)
if is_media:
ffprobe_bin = os.environ.get('FFPROBE_PATH') or shutil.which('ffprobe')
if ffprobe_bin:
try:
cmd = [ffprobe_bin, '-v', 'quiet', '-print_format', 'json', '-show_format', str(file_path)]
creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding='utf-8',
errors='ignore',
check=True,
creationflags=creationflags
)
ff_data = json.loads(result.stdout)
tags = ff_data.get('format', {}).get('tags', {})
if isinstance(tags, dict):
for value in tags.values():
if not isinstance(value, str):
continue
if value.strip().startswith('{'):
analyze_json(value)
elif '{' in value:
for json_str in _scan_bytes_for_workflow(value.encode('utf-8', errors='ignore')):
analyze_json(json_str)
except Exception:
pass
# Fast image metadata path (PNG/JPG/WEBP text chunks, EXIF)
if Image:
try:
with Image.open(file_path) as img:
for key in ['workflow', 'prompt']:
value = img.info.get(key)
if isinstance(value, str) and value.strip().startswith('{'):
analyze_json(value)
exif_data = img.info.get('exif')
if isinstance(exif_data, bytes):
for json_str in _scan_bytes_for_workflow(exif_data):
analyze_json(json_str)
except Exception:
pass
# Ultimate fallback: scan raw bytes (works for video/audio metadata blobs too)
if not found:
try:
with open(file_path, 'rb') as f:
content = f.read()
for json_str in _scan_bytes_for_workflow(content):
analyze_json(json_str)
if 'ui' in found and 'api' in found:
break
except Exception:
pass
if 'ui' in found:
return found['ui'], 'ui'
if 'api' in found:
return found['api'], 'api'
return None, None
def _parse_parameters_text(params_text, parsed):
if not params_text or not isinstance(params_text, str):
return
text = params_text.strip()
if not text:
return
# Positive prompt (explicit label, if present)
pos_match = re.search(
r'(?:^|\n)Positive prompt:\s*(.+?)(?=\n(?:Negative prompt:|Steps:|Seed:|Sampler:|CFG(?: scale)?:|CfgScale:|Size:|Model:|Scheduler:|$))',
text,
re.DOTALL,
)
if pos_match and not parsed['prompt']:
parsed['prompt'] = pos_match.group(1).strip()
# Fallback: extract unlabeled positive prompt block (common in A1111/Forge parameters)
if not parsed['prompt']:
marker_match = re.search(
r'(?m)^\s*(Negative prompt:|Steps:|Seed:|Sampler:|CFG(?: scale)?:|CfgScale:|Size:|Model:|Scheduler:)',
text,
)
prompt_block = text[:marker_match.start()] if marker_match else text
prompt_block = prompt_block.strip()
first_line = prompt_block.split('\n', 1)[0].strip() if prompt_block else ''
if prompt_block and not re.search(
r'^(Steps:|Seed:|Sampler:|CFG(?: scale)?:|CfgScale:|Size:|Model:|Scheduler:)',
first_line,
):
parsed['prompt'] = prompt_block
# Negative prompt
neg_match = re.search(
r'(?:^|\n)Negative prompt:\s*(.+?)(?=\n(?:Steps:|Seed:|Sampler:|CFG(?: scale)?:|CfgScale:|Size:|Model:|Scheduler:|$))',
text,
re.DOTALL,
)
if neg_match and not parsed['negative_prompt']:
parsed['negative_prompt'] = neg_match.group(1).strip()
# Standard parameters
steps_match = re.search(r'Steps:\s*(\d+)', text)
if steps_match and not parsed['steps']:
parsed['steps'] = int(steps_match.group(1))
sampler_match = re.search(r'Sampler:\s*([^,\n]+)', text)
if sampler_match and not parsed['sampler']:
parsed['sampler'] = sampler_match.group(1).strip()
scheduler_match = re.search(r'Scheduler:\s*([^,\n]+)', text)
if scheduler_match and not parsed['scheduler']:
parsed['scheduler'] = scheduler_match.group(1).strip()
cfg_match = re.search(r'CFG scale:\s*([\d.]+)', text) or re.search(r'CfgScale:\s*([\d.]+)', text)
if cfg_match and not parsed['cfg']:
parsed['cfg'] = float(cfg_match.group(1))
seed_match = re.search(r'Seed:\s*(\d+)', text)
if seed_match and not parsed['seed']:
parsed['seed'] = int(seed_match.group(1))
size_match = re.search(r'Size:\s*(\d+)x(\d+)', text)
if size_match and not parsed['width'] and not parsed['height']:
parsed['width'] = int(size_match.group(1))
parsed['height'] = int(size_match.group(2))
model_match = re.search(r'Model:\s*([^,\n]+)', text)
if model_match and not parsed['model']:
parsed['model'] = model_match.group(1).strip()
# Extra fields
extra_fields = {
"Model hash": r'Model hash:\s*([^,\n]+)',
"VAE": r'VAE:\s*([^,\n]+)',
"VAE hash": r'VAE hash:\s*([^,\n]+)',
"Clip skip": r'Clip skip:\s*([^,\n]+)',
"Denoising strength": r'Denoising strength:\s*([\d.]+)',
"Hires steps": r'Hires steps:\s*([^,\n]+)',
"Hires upscale": r'Hires upscale:\s*([^,\n]+)',
"Hires resize": r'Hires resize:\s*([^,\n]+)',
"Refiner": r'Refiner:\s*([^,\n]+)',
"Refiner switch": r'Refiner switch:\s*([^,\n]+)',
"Version": r'Version:\s*([^,\n]+)',
"RNG": r'RNG:\s*([^,\n]+)'
}
for label, pattern in extra_fields.items():
m = re.search(pattern, text)
if m:
parsed['extras'][label] = m.group(1).strip()
# Extract LoRAs from prompt text
if text:
lora_tags = re.findall(r'<lora:([^:>]+):([\d.]+)>', text)
for lora_name, strength in lora_tags:
lora_name = lora_name.strip()
if lora_name and lora_name not in ['None', '', 'ComfyUI']:
if not any(l['name'] == lora_name for l in parsed['loras']):
parsed['loras'].append({
'name': lora_name,
'strength_model': float(strength),
'strength_clip': float(strength)
})
def _sanitize_prompt_text(prompt_text):
"""Remove inline LoRA tags from display prompt while preserving line structure."""
if not isinstance(prompt_text, str):
return prompt_text
cleaned = re.sub(r'[ \t]*<lora:[^>]+>[ \t]*', ' ', prompt_text, flags=re.IGNORECASE)
out_lines = []
for line in cleaned.splitlines():
line = re.sub(r'[ \t]+', ' ', line).strip()
line = re.sub(r'\s+,', ',', line)
line = re.sub(r',\s*,', ',', line)
if line:
out_lines.append(line)
return '\n'.join(out_lines).strip()
def _decode_json_maybe(value):
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return value
return value
def _is_link_ref(value):
return isinstance(value, (list, tuple)) and len(value) >= 1 and isinstance(value[0], (str, int))
def _node_title(node):
if not isinstance(node, dict):
return ''
meta = node.get('_meta') or {}
if not isinstance(meta, dict):
meta = {}
return str(node.get('title') or meta.get('title') or '')
def _node_type(node):
if not isinstance(node, dict):
return ''
return str(node.get('class_type') or node.get('type') or '')
def _iter_ui_workflow_nodes(workflow_data):
"""Yield (node, id_prefix, subgraph_name) for top-level and definitions.subgraphs UI nodes."""
if not isinstance(workflow_data, dict):
return
for node in workflow_data.get('nodes', []) or []:
if isinstance(node, dict):
yield node, '', None
definitions = workflow_data.get('definitions')
if not isinstance(definitions, dict):
return
for subgraph in definitions.get('subgraphs', []) or []:
if not isinstance(subgraph, dict):
continue
subgraph_id = _safe_str(subgraph.get('id') or subgraph.get('name') or 'subgraph')
subgraph_name = _safe_str(subgraph.get('name') or subgraph_id)
for node in subgraph.get('nodes', []) or []:
if isinstance(node, dict):
yield node, f'{subgraph_id}:', subgraph_name
def _coerce_number(value):
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
text = value.strip()
if re.fullmatch(r'-?\d+', text):
return int(text)
if re.fullmatch(r'-?\d+\.\d+', text):
return float(text)
return None
def _resolve_api_value(prompt_graph, value, preferred_names=None, want='text', visited=None):
"""Resolve a ComfyUI API graph input, following links through text/prompt/string/size helper nodes."""
if visited is None:
visited = set()
preferred_names = preferred_names or []
if _is_link_ref(value):
node_key = str(value[0])
if node_key in visited:
return None
visited.add(node_key)
ref_node = prompt_graph.get(node_key)
if not isinstance(ref_node, dict):
return None
return _extract_api_node_value(prompt_graph, ref_node, preferred_names, want, visited)
if want == 'number':
return _coerce_number(value)
if isinstance(value, str):
return value.strip()
if value is not None and want != 'text':
return value
return None
def _extract_api_node_value(prompt_graph, node, preferred_names=None, want='text', visited=None):
preferred_names = preferred_names or []
visited = visited or set()
inputs = node.get('inputs', {}) if isinstance(node, dict) else {}
if not isinstance(inputs, dict):
return None
node_type_l = _node_type(node).lower()
if want == 'text' and 'stringconcatenate' in node_type_l:
delimiter = inputs.get('delimiter', '')
if not isinstance(delimiter, str):
delimiter = ''
parts = []
for key in sorted([k for k in inputs if str(k).startswith('string_')]):
part = _resolve_api_value(prompt_graph, inputs.get(key), want='text', visited=set(visited))
if isinstance(part, str) and part.strip():
parts.append(part.strip())
if parts:
return delimiter.join(parts).strip()
for name in preferred_names:
if name in inputs:
resolved = _resolve_api_value(prompt_graph, inputs.get(name), preferred_names, want, set(visited))
if resolved not in (None, ''):
return resolved
if want == 'text' and any(name in inputs for name in preferred_names):
# A preferred text/prompt field exists but is empty. Do not fall through into
# unrelated inputs such as clip/model/vae links and mistake filenames for prompts.
return None
if want == 'number':
for name in ['width', 'height', 'steps', 'cfg', 'seed', 'value', 'number', 'int', 'float']:
if name in inputs:
resolved = _resolve_api_value(prompt_graph, inputs.get(name), preferred_names, want, set(visited))
if resolved is not None:
return resolved
for value in inputs.values():
resolved = _resolve_api_value(prompt_graph, value, preferred_names, want, set(visited))
if resolved is not None:
return resolved
return None
for name in ['text', 'prompt', 'positive', 'negative', 'string', 'string_a', 'value', 'name']:
if name in inputs:
resolved = _resolve_api_value(prompt_graph, inputs.get(name), preferred_names, want, set(visited))
if isinstance(resolved, str) and resolved.strip():
return resolved.strip()
for value in inputs.values():
resolved = _resolve_api_value(prompt_graph, value, preferred_names, want, set(visited))
if isinstance(resolved, str) and resolved.strip():
return resolved.strip()
return None
def _add_lora(parsed, name, strength_model=1.0, strength_clip=None):
if not name:
return
lora_name = str(name).strip()
if lora_name in ['None', '', 'ComfyUI']:
return
if any(l['name'] == lora_name for l in parsed['loras']):
return
if strength_clip is None:
strength_clip = strength_model
parsed['loras'].append({
'name': lora_name,
'strength_model': strength_model,
'strength_clip': strength_clip,
})
def parse_comfy_metadata(metadata):
"""Parse ComfyUI/SD metadata with broad heuristics"""
parsed = {
'prompt': None,
'negative_prompt': None,
'seed': None,
'steps': None,
'cfg': None,
'sampler': None,
'scheduler': None,
'model': None,
'loras': [],
'width': None,
'height': None,
'extras': {}
}
try:
# Try any known text-like fields first
negative_prompt_keys = {'Negative prompt', 'negative_prompt', 'negativePrompt'}
for key in [
'parameters',
'Comment',
'Description',
'UserComment',
'ImageDescription',
'Software',
'Prompt',
'prompt',
'Negative prompt',
'negative_prompt',
'negativePrompt',
'notes',
]:
if key in metadata and isinstance(metadata[key], str):
if key in negative_prompt_keys and not parsed['negative_prompt']:
parsed['negative_prompt'] = metadata[key].strip()
continue
_parse_parameters_text(metadata[key], parsed)
# If there is EXIF, scan for prompt-like text
exif = metadata.get('EXIF')
if isinstance(exif, dict):
for k, v in exif.items():
if isinstance(v, str) and any(x in v for x in ['Steps:', 'Sampler:', 'Negative prompt:', 'CFG']):
_parse_parameters_text(v, parsed)
# First check if there's a 'parameters' field (Automatic1111/Forge/WebUI format)
if 'parameters' in metadata:
params_text = metadata['parameters']
if isinstance(params_text, str):
lines = params_text.split('\n')
full_text = '\n'.join(lines[1:]) if len(lines) > 1 else ''
# Also extract from Lora hashes field
lora_match = re.search(r'Lora hashes:\s*"([^"]+)"', full_text)
if lora_match:
lora_text = lora_match.group(1)
lora_pairs = lora_text.split(',')
for pair in lora_pairs:
if ':' in pair:
lora_name = pair.split(':')[0].strip()
# Only add valid LoRA names and avoid duplicates
if lora_name and lora_name not in ['None', '', 'ComfyUI']:
if not any(l['name'] == lora_name for l in parsed['loras']):
# Try to extract strength from prompt
strength_match = re.search(rf'<lora:{re.escape(lora_name)}:([\d.]+)>', parsed['prompt'] or '')
strength = float(strength_match.group(1)) if strength_match else 1.0
parsed['loras'].append({
'name': lora_name,
'strength_model': strength,
'strength_clip': strength
})
# ComfyUI UI workflow format. Newer ComfyUI can keep important nodes inside
# workflow.definitions.subgraphs, so iterate both top-level and subgraph nodes.
workflow = _decode_json_maybe(metadata.get('workflow', {}))
if isinstance(workflow, dict):
for node, _id_prefix, _subgraph_name in _iter_ui_workflow_nodes(workflow):
node_type = _node_type(node)
node_type_l = node_type.lower()
widgets = node.get('widgets_values', [])
inputs = node.get('inputs', {})
title = _node_title(node).lower()
# Extract prompts from core and custom text-encode/prompt nodes.
if any(kw in node_type_l for kw in ['cliptextencode', 'textencode', 'conditioning', 'prompttext']):
text = widgets[0] if widgets and isinstance(widgets[0], str) else None
if not text and isinstance(inputs, dict):
text = inputs.get('text') or inputs.get('prompt')
if text:
is_negative = any(word in title for word in ['negative', 'neg'])
if is_negative and not parsed['negative_prompt']:
parsed['negative_prompt'] = text
elif not is_negative and not parsed['prompt']:
parsed['prompt'] = text
# Extract sampler settings.
if 'ksampler' in node_type_l:
if not parsed['seed']:
parsed['seed'] = widgets[0] if widgets else (inputs.get('seed') if isinstance(inputs, dict) else None)
if not parsed['steps']:
parsed['steps'] = widgets[1] if len(widgets) > 1 else (inputs.get('steps') if isinstance(inputs, dict) else None)
if not parsed['cfg']:
parsed['cfg'] = widgets[2] if len(widgets) > 2 else (inputs.get('cfg') if isinstance(inputs, dict) else None)
if not parsed['sampler']:
parsed['sampler'] = widgets[3] if len(widgets) > 3 else (inputs.get('sampler_name') if isinstance(inputs, dict) else None)
if not parsed['scheduler']:
parsed['scheduler'] = widgets[4] if len(widgets) > 4 else (inputs.get('scheduler') if isinstance(inputs, dict) else None)
# Extract model/checkpoint names.
if any(kw in node_type_l for kw in ['checkpointloader', 'unetloader', 'modelloader']):
if not parsed['model']:
parsed['model'] = widgets[0] if widgets else None
if not parsed['model'] and isinstance(inputs, dict):
for key in ['ckpt_name', 'unet_name', 'model_name', 'name']:
if inputs.get(key):
parsed['model'] = inputs.get(key)
break
# Extract LoRAs from LoRA Manager, standard loaders, stackers, and custom loaders.
if any(kw in node_type_l for kw in ['lora', 'loraloader', 'lora stacker', 'lora_stack']):
try:
if widgets and isinstance(widgets[0], list):
for lora_obj in widgets[0]:
if isinstance(lora_obj, dict) and lora_obj.get('active', True):
_add_lora(
parsed,
lora_obj.get('name'),
lora_obj.get('strength', 1.0),
lora_obj.get('clipStrength', lora_obj.get('strength', 1.0)),
)
else:
lora_name = widgets[0] if widgets else None
strength_model = widgets[1] if len(widgets) > 1 else 1.0
strength_clip = widgets[2] if len(widgets) > 2 else strength_model
if isinstance(inputs, dict):
lora_name = lora_name or inputs.get('lora_name') or inputs.get('name')
strength_model = inputs.get('strength_model', inputs.get('lora_strength', inputs.get('strength', strength_model)))
strength_clip = inputs.get('strength_clip', inputs.get('clip_strength', inputs.get('clipStrength', strength_model)))
_add_lora(parsed, lora_name, strength_model, strength_clip)
except Exception as e:
print(f"Error parsing LoRAs from node {node_type}: {e}")
# Extract dimensions.
if 'emptylatentimage' in node_type_l or ('latent' in node_type_l and isinstance(inputs, dict) and 'width' in inputs and 'height' in inputs):
if not parsed['width']:
parsed['width'] = widgets[0] if widgets else inputs.get('width')
if not parsed['height']:
parsed['height'] = widgets[1] if len(widgets) > 1 else inputs.get('height')
# Parse API prompt graph JSON (ComfyUI default Save Image metadata). This is the
# most reliable source for images saved by core SaveImage and most custom save nodes.
prompt_graph = _decode_json_maybe(metadata.get('prompt'))
if isinstance(prompt_graph, dict):
text_candidates = []
api_negative_input_seen = False
for _, node in prompt_graph.items():
if not isinstance(node, dict):
continue
node_type = _node_type(node)
node_type_l = node_type.lower()
title_l = _node_title(node).lower()
node_inputs = node.get('inputs', {})
if not isinstance(node_inputs, dict):
continue
if 'ksampler' in node_type_l:
# Prefer the API prompt graph over the UI workflow for sampler settings;
# UI widgets include control widgets (for example randomize/fixed) that
# shifted position in recent ComfyUI versions.
if node_inputs.get('seed') is not None:
parsed['seed'] = node_inputs.get('seed')
if node_inputs.get('steps') is not None:
parsed['steps'] = node_inputs.get('steps')
if node_inputs.get('cfg') is not None:
parsed['cfg'] = node_inputs.get('cfg')
if node_inputs.get('sampler_name') is not None:
parsed['sampler'] = node_inputs.get('sampler_name')
if node_inputs.get('scheduler') is not None:
parsed['scheduler'] = node_inputs.get('scheduler')
positive_text = _resolve_api_value(prompt_graph, node_inputs.get('positive'), ['text', 'prompt', 'positive', 'string_a', 'string'], 'text')
if positive_text:
parsed['prompt'] = positive_text
if 'negative' in node_inputs:
api_negative_input_seen = True
negative_text = _resolve_api_value(prompt_graph, node_inputs.get('negative'), ['text', 'prompt', 'negative', 'string_a', 'string'], 'text')
if negative_text:
parsed['negative_prompt'] = negative_text
elif 'negative' in node_inputs:
# The API graph explicitly says the negative input is empty. Do not keep
# stale/wrong text guessed from UI widgets (recent ComfyUI widgets moved).
parsed['negative_prompt'] = None
if any(key in node_inputs for key in ['ckpt_name', 'unet_name', 'model_name']) or any(kw in node_type_l for kw in ['checkpointloader', 'unetloader', 'modelloader', 'ditloader']):
for key in ['ckpt_name', 'unet_name', 'model_name', 'name']:
if isinstance(node_inputs.get(key), str) and node_inputs.get(key).strip():
parsed['model'] = node_inputs.get(key).strip()
break
if any(kw in node_type_l for kw in ['lora', 'loraloader', 'lora_stack', 'lora stacker']):
lora_name = _resolve_api_value(prompt_graph, node_inputs.get('lora_name') or node_inputs.get('name'), ['lora_name', 'name'], 'text')
strength_model = node_inputs.get('strength_model', node_inputs.get('lora_strength', node_inputs.get('strength', 1.0)))
strength_clip = node_inputs.get('strength_clip', node_inputs.get('clip_strength', node_inputs.get('clipStrength', strength_model)))
_add_lora(parsed, lora_name, strength_model, strength_clip)
if 'emptylatentimage' in node_type_l or ('latent' in node_type_l and 'width' in node_inputs and 'height' in node_inputs):
width_value = _resolve_api_value(prompt_graph, node_inputs.get('width'), ['width'], 'number')
height_value = _resolve_api_value(prompt_graph, node_inputs.get('height'), ['height'], 'number')
if width_value is not None:
parsed['width'] = width_value
if height_value is not None:
parsed['height'] = height_value
if any(kw in node_type_l for kw in ['cliptextencode', 'textencode', 'conditioning', 'prompt']):
text = _extract_api_node_value(prompt_graph, node, ['text', 'prompt', 'string_a', 'string'], 'text')
if isinstance(text, str) and text.strip():
text_candidates.append((text.strip(), title_l))
if not parsed['negative_prompt'] and not api_negative_input_seen:
for text, title in text_candidates:
if any(word in title for word in ['negative', 'neg']):
parsed['negative_prompt'] = text
break
if not parsed['prompt']:
for text, title in text_candidates:
if not any(word in title for word in ['negative', 'neg']):
parsed['prompt'] = text
break
# Do not invent a negative prompt from arbitrary extra text nodes. If a
# workflow has a real negative prompt, it is either linked from the sampler
# negative input or explicitly titled negative/neg above.
if text_candidates and not parsed['prompt']:
for text, _ in text_candidates:
parsed['prompt'] = text
break
except Exception as e:
print(f"Error parsing metadata: {e}")
import traceback
traceback.print_exc()
# Final normalization: prompt display should not duplicate LoRA entries.
parsed['prompt'] = _sanitize_prompt_text(parsed.get('prompt'))
parsed['negative_prompt'] = _sanitize_prompt_text(parsed.get('negative_prompt'))
if parsed.get('prompt') and parsed.get('negative_prompt') == parsed.get('prompt'):
# Some workflows/models have no real negative prompt but the default ComfyUI
# SaveImage API graph still routes the same conditioning/text to positive and
# negative. Showing the same block twice is misleading; treat exact duplicates
# as an absent negative prompt.
parsed['negative_prompt'] = None
return parsed
def flatten_metadata(metadata):
flat = {}
for key, value in metadata.items():
if key == 'workflow':
continue
if isinstance(value, dict):
for k2, v2 in value.items():
flat[f"{key}.{k2}"] = _safe_str(v2)
else:
flat[key] = _safe_str(value)
return flat
def _normalize_node_params_from_ui_node(node):
params = []
inputs = node.get('inputs', {})
if isinstance(inputs, list):
for idx, item in enumerate(inputs):
if isinstance(item, dict):
name = item.get('name') or f"input_{idx + 1}"
value = item.get('widget', item.get('value', item.get('link')))
params.append({'name': str(name), 'value': _safe_str(value)})
elif isinstance(inputs, dict):
for name, value in inputs.items():
params.append({'name': str(name), 'value': _safe_str(value)})
widgets = node.get('widgets_values', [])
if isinstance(widgets, list):
for idx, value in enumerate(widgets):
params.append({'name': f"widget_{idx + 1}", 'value': _safe_str(value)})
return params
def extract_workflow_nodes(metadata):
"""Extract workflow nodes for sidebar node inspector (UI and API workflow formats)."""
workflow_data = metadata.get('workflow')
prompt_data = metadata.get('prompt')
source = workflow_data if workflow_data is not None else prompt_data
if isinstance(source, str):
try:
source = json.loads(source)
except Exception:
return []
nodes_out = []
# UI format: {"nodes":[...], "definitions":{"subgraphs":[...]}}
if isinstance(source, dict) and isinstance(source.get('nodes'), list):
for node, id_prefix, subgraph_name in _iter_ui_workflow_nodes(source):
if not isinstance(node, dict):
continue
if node.get('mode', 0) != 0:
continue
node_id = node.get('id', 'N/A')
node_type = node.get('type') or node.get('class_type') or 'Unknown'
params = _normalize_node_params_from_ui_node(node)
if subgraph_name:
params.insert(0, {'name': 'subgraph', 'value': subgraph_name})
nodes_out.append({
'id': f"{id_prefix}{_safe_str(node_id)}",
'type': _safe_str(node_type),
'params': params
})
# API format: {"3":{"class_type":"KSampler","inputs":{...}}, ...}
elif isinstance(source, dict):
for node_id, node in source.items():
if not isinstance(node, dict) or 'class_type' not in node:
continue
params = []
inputs = node.get('inputs', {})
if isinstance(inputs, dict):
for name, value in inputs.items():
params.append({'name': str(name), 'value': _safe_str(value)})
nodes_out.append({
'id': _safe_str(node_id),
'type': _safe_str(node.get('class_type', 'Unknown')),
'params': params
})
def _sort_key(item):
node_id = item.get('id', '')
try:
return (0, int(node_id))
except Exception:
return (1, str(node_id))
nodes_out.sort(key=_sort_key)
return nodes_out
# --- API Endpoints ---
@server.PromptServer.instance.routes.get("/gemini-image-browser/list")
async def list_images(request):
print("\n--- ComfyUI Image Browser: Received list request ---")
try:
page = int(request.query.get('page', 0))
per_page = int(request.query.get('per_page', 50))
sort = request.query.get('sort', 'date_desc')
search = request.query.get('search', '').lower()
folder_filter = request.query.get('folder', 'default')
show_favorites = request.query.get('favorites', 'false') == 'true'
print(f"Params: page={page}, sort='{sort}', search='{search}', folder='{folder_filter}', favorites={show_favorites}")
output_dir = Path(folder_paths.get_output_directory()).expanduser().resolve()
favorites = load_json(FAVORITES_FILE)
additional_folders = load_json(ADDITIONAL_FOLDERS_FILE)
scan_targets = []
folder_map = _build_folder_map()
print(f"Folder map contains {len(folder_map)} folder(s).")
if folder_filter == 'all':
scan_targets = list(folder_map.items())
elif folder_filter in folder_map and folder_map[folder_filter].exists():
scan_targets.append((folder_filter, folder_map[folder_filter]))
else: # Fallback to default
scan_targets.append(('default', output_dir))
print(f"Scanning {len(scan_targets)} folder target(s).")
all_files = []
for folder_id, scan_path in scan_targets:
if not scan_path.exists():
print(f"Warning: Scan path {scan_path} does not exist.")
continue
for ext in ['*.png', '*.jpg', '*.jpeg', '*.webp', '*.gif', '*.mp4', '*.webm', '*.mov', '*.mp3', '*.wav', '*.ogg', '*.flac', '*.m4a']:
for p in scan_path.glob(ext):
all_files.append({'folder_id': folder_id, 'path_obj': p})
print(f"Found {len(all_files)} total files before filtering.")
file_info = []
for item in all_files:
folder_id, path_obj = item['folder_id'], item['path_obj']
base_dir = folder_map.get(folder_id)
if not base_dir: continue
relative_path = str(path_obj.resolve().relative_to(base_dir))
fav_key = f"{folder_id}:{relative_path}"
if show_favorites and fav_key not in favorites: continue
if search and search not in path_obj.name.lower(): continue
stat = path_obj.stat()
file_info.append({
'name': path_obj.name, 'path': relative_path, 'size': stat.st_size,
'modified': stat.st_mtime, 'is_favorite': fav_key in favorites,
'folder_id': folder_id
})
print(f"Returning {len(file_info)} files after filtering.")
if sort == 'date_desc': file_info.sort(key=lambda x: x['modified'], reverse=True)
elif sort == 'date_asc': file_info.sort(key=lambda x: x['modified'])
elif sort == 'name_asc': file_info.sort(key=lambda x: x['name'].lower())
elif sort == 'name_desc': file_info.sort(key=lambda x: x['name'].lower(), reverse=True)
start_index = page * per_page
paginated_files = file_info[start_index : start_index + per_page]
print("-------------------------------------------------")
return web.json_response({
'images': paginated_files,
'total': len(file_info),
'has_more': (start_index + per_page) < len(file_info),
'folders': [{'id': 'default', 'name': 'ComfyUI Output', 'path': str(output_dir)}] + [
f for f in additional_folders if f.get('id') in folder_map and f.get('id') != 'default'
]
})
except Exception as e:
print(f"Error in list_images: {e}")
import traceback
traceback.print_exc()
return web.json_response({'error': str(e)}, status=500)
@server.PromptServer.instance.routes.get("/gemini-image-browser/metadata")
async def get_metadata_endpoint(request):
try:
filename = request.query.get('filename')
folder_id = request.query.get('folder', 'default')
if not filename:
return web.json_response({'error': 'Missing filename'}, status=400)
base_dir = _resolve_folder_path(folder_id)
if not base_dir:
return web.json_response({'error': 'Folder not found'}, status=404)
file_path = _resolve_file_from_request(base_dir, filename)
if not file_path:
return web.json_response({'error': 'Invalid file path'}, status=400)
if not file_path.exists():
print(f"Metadata: File not found at {file_path}")
return web.json_response({'error': 'File not found'}, status=404)
# Check if it's a video file
is_video = file_path.suffix.lower() in ['.mp4', '.webm', '.mov']
# Check if it's an audio file
is_audio = file_path.suffix.lower() in ['.mp3', '.wav', '.ogg', '.flac', '.m4a']
# Extract metadata for images, and workflow payload for video/audio
raw_metadata = {}
if not is_video and not is_audio:
raw_metadata = extract_metadata(file_path)
workflow_obj, workflow_type = extract_workflow_from_file(file_path)
if workflow_obj:
if workflow_type == 'ui':
raw_metadata['workflow'] = workflow_obj
else:
raw_metadata['prompt'] = workflow_obj
# Parse metadata
parsed_metadata = parse_comfy_metadata(raw_metadata)
workflow_nodes = extract_workflow_nodes(raw_metadata)
# Get media dimensions
dimensions = {}
if not is_video and not is_audio and Image:
try:
with Image.open(file_path) as img:
dimensions = {'width': img.width, 'height': img.height}
# Fill in parsed dimensions if missing
if not parsed_metadata.get('width'):
parsed_metadata['width'] = img.width
if not parsed_metadata.get('height'):
parsed_metadata['height'] = img.height
except:
dimensions = {'width': 'N/A', 'height': 'N/A'}
else:
dimensions = {'width': 'N/A', 'height': 'N/A'}
# Debug logging (disabled)
# print(f"\n{'='*60}")
# print(f"Metadata Debug for: {filename}")
# print(f"{'='*60}")
# print(f"File path: {file_path}")
# print(f"File exists: {file_path.exists()}")
# print(f"Raw metadata keys: {list(raw_metadata.keys())}")
# print(f"Has workflow: {'Yes' if 'workflow' in raw_metadata else 'No'}")
# print(f"Has parameters: {'Yes' if 'parameters' in raw_metadata else 'No'}")
#
# print(f"\nParsed Metadata:")
# print(f" Prompt: {'Found (' + str(len(parsed_metadata['prompt'])) + ' chars)' if parsed_metadata['prompt'] else 'None'}")
# print(f" Negative: {'Found (' + str(len(parsed_metadata['negative_prompt'])) + ' chars)' if parsed_metadata['negative_prompt'] else 'None'}")
# print(f" Model: {parsed_metadata['model']}")
# print(f" Seed: {parsed_metadata['seed']}")
# print(f" Steps: {parsed_metadata['steps']}")
# print(f" CFG: {parsed_metadata['cfg']}")
# print(f" Sampler: {parsed_metadata['sampler']}")
# print(f" Scheduler: {parsed_metadata['scheduler']}")
# print(f" Dimensions: {parsed_metadata['width']}x{parsed_metadata['height']}")
# print(f" LoRAs found: {len(parsed_metadata['loras'])}")
# for lora in parsed_metadata['loras']:
# print(f" - {lora['name']} (Model: {lora['strength_model']}, CLIP: {lora['strength_clip']})")
# print(f"{'='*60}\n")
# Return comprehensive metadata response
return web.json_response({
'parsed': parsed_metadata,
'raw': {k: (v[:500] + "…") if len(v) > 500 else v
for k, v in flatten_metadata(raw_metadata).items()},
'extra': parsed_metadata.get('extras', {}),
'workflow_nodes': workflow_nodes,
'dimensions': dimensions,
'file_info': {
'size': file_path.stat().st_size,
'modified': file_path.stat().st_mtime
},
'is_favorite': f"{folder_id}:{filename}" in load_json(FAVORITES_FILE),
'is_video': is_video,
'is_audio': is_audio
})
except Exception as e:
print(f"Error getting metadata: {e}")
import traceback
traceback.print_exc()
return web.json_response({'error': str(e)}, status=500)
@server.PromptServer.instance.routes.post("/gemini-image-browser/favorite")
async def toggle_favorite_endpoint(request):
data = await request.json()
folder_id = data.get('folder_id')
filename = data.get('filename')
if not filename or not folder_id: return web.json_response({'error': 'Missing filename or folder ID'}, status=400)
fav_key = f"{folder_id}:{filename}"
favorites = load_json(FAVORITES_FILE)
is_favorite = fav_key not in favorites
if is_favorite:
favorites.append(fav_key)
else:
favorites.remove(fav_key)
save_json(favorites, FAVORITES_FILE)
return web.json_response({'success': True, 'is_favorite': is_favorite})
@server.PromptServer.instance.routes.post("/gemini-image-browser/delete")
async def delete_image_endpoint(request):
"""Delete an image file"""
try:
data = await request.json()
filename = data.get('filename')
folder_id = data.get('folder_id', 'default')
if not filename:
return web.json_response({'error': 'Missing filename'}, status=400)
base_dir = _resolve_folder_path(folder_id)
if not base_dir:
return web.json_response({'error': 'Folder not found'}, status=404)
file_path = _resolve_file_from_request(base_dir, filename)
if not file_path:
return web.json_response({'error': 'Invalid file path'}, status=400)
# Check if file exists
if not file_path.exists():
return web.json_response({'error': 'File not found'}, status=404)
# Delete the file
file_path.unlink()
# Also remove from favorites if it was favorited
fav_key = f"{folder_id}:{filename}"
favorites = load_json(FAVORITES_FILE)
if fav_key in favorites:
favorites.remove(fav_key)
save_json(favorites, FAVORITES_FILE)
print(f"Deleted file: {file_path}")
return web.json_response({'success': True})
except Exception as e:
print(f"Error deleting file: {e}")
import traceback
traceback.print_exc()
return web.json_response({'error': str(e)}, status=500)
@server.PromptServer.instance.routes.post("/gemini-image-browser/add_folder")
async def add_folder_endpoint(request):
data = await request.json()
path = data.get('path')
name = data.get('name')
if not path or not name: return web.json_response({'error': 'Missing path or name'}, status=400)
try:
normalized_path = Path(path).expanduser().resolve()
except Exception:
return web.json_response({'error': 'Invalid folder path'}, status=400)
if not normalized_path.exists() or not normalized_path.is_dir():
return web.json_response({'error': 'Folder does not exist'}, status=400)
folders = load_json(ADDITIONAL_FOLDERS_FILE)
folder_id = f"folder_{len(folders)}_{os.urandom(2).hex()}"
new_folder = {'id': folder_id, 'name': name, 'path': str(normalized_path)}
folders.append(new_folder)
save_json(folders, ADDITIONAL_FOLDERS_FILE)
return web.json_response({'success': True, 'folder': new_folder})
@server.PromptServer.instance.routes.post("/gemini-image-browser/remove_folder")
async def remove_folder_endpoint(request):
data = await request.json()
folder_id = data.get('folder_id')
if not folder_id: return web.json_response({'error': 'Missing folder_id'}, status=400)
folders = load_json(ADDITIONAL_FOLDERS_FILE)
folders = [f for f in folders if f.get('id') != folder_id]
save_json(folders, ADDITIONAL_FOLDERS_FILE)
return web.json_response({'success': True})
@server.PromptServer.instance.routes.get("/gemini-image-browser/thumbnail")
async def get_thumbnail(request):
filename = request.query.get('filename')
folder_id = request.query.get('folder', 'default')
if not filename: return web.Response(status=400)
base_dir = _resolve_folder_path(folder_id)
if not base_dir:
return web.Response(status=404)
file_path = _resolve_file_from_request(base_dir, filename)
if not file_path:
return web.Response(status=400)
if not file_path.exists():
return web.Response(status=404)
return web.FileResponse(file_path)
# --- UI Routes ---
@server.PromptServer.instance.routes.get("/gemini-image-browser/ui")
async def get_ui(request):
ui_path = Path(__file__).parent / "ui.html"
return web.FileResponse(ui_path)
@server.PromptServer.instance.routes.get("/gemini-image-browser/js")
async def get_js(request):
js_path = Path(__file__).parent / "browser.js"
return web.FileResponse(js_path)
# --- Node Mappings ---
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
_initialize_persistent_state()
print("--- ComfyUI Image Browser ---")
print(f"Loaded v{__version__}. UI available via button in menu.")
print(f"Data directory: {STATE_DIRECTORY}")