Files
Eric Rollei 1f9fa9b670 Add backward compatibility, XMP regions, per-node hooks toggle, and enhancements
Major Features:
- Backward compatibility: VALIDATE_INPUTS handles old boolean True/False values for enable_runtime_hooks parameter
- Phase 3: XMP region metadata with MWG-RS compliance (faces, areas, coordinates, extensions)
- Phase 4.4: Per-node UI toggle for runtime hooks (dropdown: Auto/Enabled/Disabled)
- Runtime hooks enhancements: performance tracking, disagreement logging, configurable limits

New Files:
- config.json: Configuration file for runtime hooks and metadata settings
- eric_metadata/hooks/runtime_capture.py: Runtime hook infrastructure
- eric_metadata/utils/config.py: Configuration loader with priority system
- eric_metadata/utils/version.py: Version helper for generated_by labels
- eric_metadata/utils/a1111.py: Automatic1111 parameter string generation
- eric_metadata/utils/jpeg_fallback.py: 4-stage JPEG metadata fallback
- eric_metadata/workflow/: Workflow parsing and generation helpers
- tests/test_backward_compatibility.py: 7 tests for parameter migration
- tests/test_xmp_regions.py: 6 tests for XMP region round-trip
- tests/test_per_node_runtime_hooks.py: 9 tests for per-node toggle

Documentation:
- CONFIG_GUIDE.md: Configuration options and usage
- RUNTIME_HOOKS_QUICK_REFERENCE.md: Quick reference for runtime hooks
- RUNTIME_HOOKS_ENHANCEMENTS_SUMMARY.md: Detailed enhancement documentation
- SAFE_RUNTIME_HOOKS_RESEARCH.md: Safety analysis and validation
- edit_log.md: Complete change history

Test Results: All 143 tests passing
- 7 backward compatibility tests
- 6 XMP region tests
- 9 per-node toggle tests
- 17 runtime hooks enhancement tests
- 104 existing tests

Breaking Changes: None - fully backward compatible
2025-11-20 01:26:04 -08:00

279 lines
7.8 KiB
Python

"""Helpers for deriving generation parameters from ComfyUI workflows.
These utilities focus on identifying sampler configuration, model selection,
latent dimensions, and other generation-related values. They are intentionally
lenient so preparation work can stabilise before we introduce strict schema
expectations.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Iterable, Optional
from .parsing import AssetDiscovery, WorkflowGraph, WorkflowParsingService
logger = logging.getLogger(__name__)
def extract_generation_parameters(
workflow: Dict[str, Any],
*,
parsing_service: Optional[WorkflowParsingService] = None,
) -> Dict[str, Any]:
"""Return a best-effort dictionary of generation controls.
The intent is not to be perfect, but to provide stable hooks for upcoming
refactors and tests. When values cannot be derived they are simply omitted.
"""
if not isinstance(workflow, dict):
return {}
parsing = parsing_service or WorkflowParsingService()
graph = WorkflowGraph.from_workflow(workflow)
nodes = graph.nodes
generation: Dict[str, Any] = {}
sampler_payload = _extract_sampler_settings(nodes)
if sampler_payload:
generation.update(sampler_payload)
dimensions = _extract_dimensions(nodes)
if dimensions:
generation.update(dimensions)
model_name = _extract_model_name(nodes)
if model_name:
generation["model"] = model_name
vae_name = _extract_first_string(nodes, ("vae", "vae_name"))
if vae_name:
generation["vae"] = vae_name
assets = parsing.discover_assets(workflow)
lora_entries = _build_lora_entries(assets)
if lora_entries:
generation["loras"] = lora_entries
return generation
def _extract_sampler_settings(nodes: Dict[str, Any]) -> Dict[str, Any]:
best_candidate: Optional[Dict[str, Any]] = None
best_score = -1
for node_id, node in nodes.items():
class_type = _get_class_type(node)
lowered = class_type.lower()
if "sampler" not in lowered:
continue
inputs = _extract_inputs(node)
score = _score_sampler_inputs(inputs)
if score > best_score:
best_candidate = inputs
best_score = score
if not best_candidate:
return {}
payload: Dict[str, Any] = {}
sampler_name = _first_string(best_candidate, ("sampler", "sampler_name"))
if sampler_name:
payload["sampler"] = sampler_name
scheduler = _first_string(best_candidate, ("scheduler", "scheduler_name"))
if scheduler:
payload["scheduler"] = scheduler
steps = _first_numeric(best_candidate, ("steps", "max_steps", "sampling_steps"), coerce=int)
if steps is not None:
payload["steps"] = steps
cfg = _first_numeric(best_candidate, ("cfg_scale", "cfg", "guidance_scale", "scale"))
if cfg is not None:
payload["cfg_scale"] = cfg
seed = _first_numeric(best_candidate, ("seed", "noise_seed", "latent_seed"), coerce=int)
if seed is not None:
payload["seed"] = seed
denoise = _first_numeric(best_candidate, ("denoise", "denoise_strength", "sigma_start"))
if denoise is not None:
payload["denoise"] = denoise
return payload
def _score_sampler_inputs(inputs: Dict[str, Any]) -> int:
if not inputs:
return -1
score = 0
for key in ("steps", "cfg", "cfg_scale", "sampler", "sampler_name", "seed"):
if key in inputs:
score += 1
return score
def _extract_dimensions(nodes: Dict[str, Any]) -> Dict[str, Any]:
for node in nodes.values():
inputs = _extract_inputs(node)
width = _first_numeric(inputs, ("width", "w"), coerce=int)
height = _first_numeric(inputs, ("height", "h"), coerce=int)
if width is not None and height is not None:
return {"width": width, "height": height}
return {}
def _extract_model_name(nodes: Dict[str, Any]) -> Optional[str]:
preferred_order = (
"CheckpointLoaderSimple",
"CheckpointLoader",
"ModelLoader",
"DiffusersLoader",
"unCLIPCheckpointLoader",
)
best_name: Optional[str] = None
best_priority = len(preferred_order)
for node in nodes.values():
class_type = _get_class_type(node)
inputs = _extract_inputs(node)
candidate = _first_string(
inputs,
(
"ckpt_name",
"model_name",
"model",
"checkpoint",
"checkpoint_name",
),
)
if not candidate:
continue
try:
priority = preferred_order.index(class_type)
except ValueError:
priority = len(preferred_order)
if best_name is None or priority < best_priority:
best_name = candidate
best_priority = priority
return best_name
def _extract_first_string(nodes: Dict[str, Any], keys: Iterable[str]) -> Optional[str]:
for node in nodes.values():
inputs = _extract_inputs(node)
candidate = _first_string(inputs, keys)
if candidate:
return candidate
return None
def _build_lora_entries(assets: AssetDiscovery) -> Optional[list]:
if not assets.loras:
return None
entries = []
for record in assets.loras:
strength = record.extra.get("strength_model")
if strength is None:
strength = record.extra.get("strength")
strength_value = _coerce_float(strength) or 1.0
clip_strength = _coerce_float(record.extra.get("clip_strength"))
payload: Dict[str, Any] = {
"name": record.name,
"strength": strength_value,
"source": record.source,
}
if clip_strength is not None:
payload["clip_strength"] = clip_strength
entries.append(payload)
return entries or None
def _get_class_type(node: Dict[str, Any]) -> str:
value = node.get("class_type") or node.get("type") or ""
return str(value)
def _extract_inputs(node: Dict[str, Any]) -> Dict[str, Any]:
raw = node.get("inputs")
if isinstance(raw, dict):
return raw
if isinstance(raw, list):
mapping: Dict[str, Any] = {}
for entry in raw:
if isinstance(entry, dict):
name = entry.get("name") or entry.get("key")
if name:
mapping[str(name)] = entry.get("value")
return mapping
return {}
def _first_string(inputs: Dict[str, Any], keys: Iterable[str]) -> Optional[str]:
value = _first(inputs, keys)
if isinstance(value, bytes):
try:
return value.decode("utf-8", errors="ignore")
except Exception:
return None
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return None
def _first_numeric(
inputs: Dict[str, Any],
keys: Iterable[str],
*,
coerce=float,
) -> Optional[float]:
value = _first(inputs, keys)
return _coerce_numeric(value, coerce=coerce)
def _first(inputs: Dict[str, Any], keys: Iterable[str]) -> Any:
for key in keys:
if key in inputs:
return inputs[key]
return None
def _coerce_numeric(value: Any, *, coerce=float) -> Optional[float]:
if value is None:
return None
if isinstance(value, (int, float)):
try:
return coerce(value)
except (TypeError, ValueError):
return None
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return None
try:
return coerce(float(stripped) if coerce is float else int(float(stripped)))
except (TypeError, ValueError):
return None
return None
def _coerce_float(value: Any) -> Optional[float]:
return _coerce_numeric(value, coerce=float)