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

388 lines
13 KiB
Python

"""Helpers for generating Automatic1111-compatible parameter strings."""
from __future__ import annotations
import re
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
from .version import get_metadata_system_version_label
METADATA_SYSTEM_GENERATED_BY = get_metadata_system_version_label()
_EMPTY_SEQUENCE = (None, "", [])
def _ensure_dict(value: Any) -> Dict[str, Any]:
return value if isinstance(value, dict) else {}
def _normalize_prompt(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, (list, tuple, set)):
parts = [_normalize_prompt(item) for item in value]
return "\n".join(part for part in parts if part)
if isinstance(value, dict):
preferred_keys = ("text", "prompt", "positive", "negative", "value", "content")
parts: List[str] = []
for key in preferred_keys:
if key in value:
part = _normalize_prompt(value[key])
if part:
parts.append(part)
if not parts:
parts = [_normalize_prompt(item) for item in value.values()]
return "\n".join(part for part in parts if part)
return str(value).strip()
def _first_text(candidates: Iterable[Any]) -> str:
for candidate in candidates:
text = _normalize_prompt(candidate)
if text:
return text
return ""
def _pick_value(*candidates: Any) -> Any:
for candidate in candidates:
if isinstance(candidate, tuple) and len(candidate) == 2:
container, key = candidate
if isinstance(container, dict) and key in container:
value = container[key]
if value not in _EMPTY_SEQUENCE:
return value
elif candidate not in _EMPTY_SEQUENCE:
return candidate
return None
def _to_int(value: Any) -> Optional[int]:
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return None
try:
return int(float(stripped))
except ValueError:
return None
return None
def _clean_name(value: Any) -> str:
if value is None:
return ""
if not isinstance(value, str):
value = str(value)
return re.sub(r"\s+", " ", value).strip()
def _coerce_float(value: Any, default: float) -> float:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
stripped = value.strip()
if stripped:
try:
return float(stripped)
except ValueError:
return default
return default
return default
def _format_strength(value: float) -> str:
formatted = f"{value:.2f}"
if "." in formatted:
formatted = formatted.rstrip("0").rstrip(".")
return formatted
def _collect_lora_entries(containers: Iterable[Any]) -> List[Dict[str, Any]]:
entries: List[Dict[str, Any]] = []
for container in containers:
if isinstance(container, list):
entries.extend(item for item in container if isinstance(item, dict))
elif isinstance(container, dict):
entries.extend(item for item in container.values() if isinstance(item, dict))
return entries
def _iter_lora_sources(*containers: Any) -> List[Any]:
sources: List[Any] = []
for container in containers:
if isinstance(container, dict):
sources.append(container)
for key, value in container.items():
if isinstance(key, str) and "lora" in key.lower():
sources.append(value)
else:
sources.append(container)
return sources
def _pick_dimension(keys: Sequence[str], sources: Sequence[Dict[str, Any]]) -> Any:
for source in sources:
for key in keys:
if key in source and source[key] not in _EMPTY_SEQUENCE:
return source[key]
return None
def generate_a1111_parameters(
metadata: Optional[Dict[str, Any]],
*,
format_strength: Optional[Callable[[float], str]] = None,
) -> str:
if not metadata or not isinstance(metadata, dict):
return ""
strength_formatter = format_strength or _format_strength
root_metadata = metadata
ai_info = _ensure_dict(root_metadata.get("ai_info"))
generation = _ensure_dict(ai_info.get("generation"))
if not generation:
generation = _ensure_dict(root_metadata.get("generation"))
prompts_section = _ensure_dict(generation.get("prompts"))
ai_prompts = _ensure_dict(ai_info.get("prompts"))
metadata_prompts = _ensure_dict(root_metadata.get("prompts"))
workflow_info = _ensure_dict(ai_info.get("workflow_info"))
workflow_prompts = _ensure_dict(workflow_info.get("prompts"))
positive_prompt = _first_text([
generation.get("positive_prompt"),
prompts_section.get("positive"),
generation.get("prompt"),
ai_prompts.get("positive"),
metadata_prompts.get("positive"),
workflow_prompts.get("positive"),
root_metadata.get("positive_prompt"),
root_metadata.get("prompt"),
])
negative_prompt = _first_text([
generation.get("negative_prompt"),
prompts_section.get("negative"),
generation.get("negative"),
generation.get("negative_prompts"),
ai_prompts.get("negative"),
metadata_prompts.get("negative"),
workflow_prompts.get("negative"),
root_metadata.get("negative_prompt"),
root_metadata.get("negative"),
])
params: List[str] = []
sampling = _ensure_dict(generation.get("sampling"))
metadata_sampling = _ensure_dict(root_metadata.get("sampling"))
workflow_sampling = _ensure_dict(workflow_info.get("sampling"))
steps = _pick_value(
(sampling, "steps"),
(workflow_sampling, "steps"),
(metadata_sampling, "steps"),
(generation, "steps"),
(root_metadata, "steps"),
)
if steps not in _EMPTY_SEQUENCE:
params.append(f"Steps: {steps}")
sampler_value = _pick_value(
(sampling, "sampler"),
(sampling, "sampler_name"),
(workflow_sampling, "sampler"),
(metadata_sampling, "sampler"),
(generation, "sampler"),
(generation, "sampler_name"),
(root_metadata, "sampler"),
)
if sampler_value not in _EMPTY_SEQUENCE:
params.append(f"Sampler: {sampler_value}")
cfg_scale = _pick_value(
(sampling, "cfg_scale"),
(sampling, "cfg"),
(workflow_sampling, "cfg_scale"),
(metadata_sampling, "cfg_scale"),
(metadata_sampling, "cfg"),
(generation, "cfg_scale"),
(generation, "cfg"),
(root_metadata, "cfg_scale"),
(root_metadata, "cfg"),
)
if cfg_scale not in _EMPTY_SEQUENCE:
params.append(f"CFG scale: {cfg_scale}")
seed_value = _pick_value(
(sampling, "seed"),
(workflow_sampling, "seed"),
(metadata_sampling, "seed"),
(generation, "seed"),
(root_metadata, "seed"),
)
if seed_value not in _EMPTY_SEQUENCE:
params.append(f"Seed: {seed_value}")
dimensions = _ensure_dict(generation.get("dimensions"))
metadata_dimensions = _ensure_dict(root_metadata.get("dimensions"))
workflow_dimensions = _ensure_dict(workflow_info.get("dimensions"))
technical = _ensure_dict(root_metadata.get("technical"))
analysis = _ensure_dict(_ensure_dict(root_metadata.get("analysis")).get("technical"))
image_info = _ensure_dict(root_metadata.get("image"))
ai_image = _ensure_dict(ai_info.get("image"))
dimension_sources: List[Dict[str, Any]] = [
dimensions,
metadata_dimensions,
workflow_dimensions,
technical,
analysis,
image_info,
ai_image,
generation,
]
width_value = _pick_dimension(("width", "W"), dimension_sources)
height_value = _pick_dimension(("height", "H"), dimension_sources)
if width_value in _EMPTY_SEQUENCE or height_value in _EMPTY_SEQUENCE:
for source in dimension_sources:
size_value = source.get("size") if isinstance(source, dict) else None
if isinstance(size_value, str) and "x" in size_value.lower():
parts = size_value.lower().replace(" ", "").split("x")
if len(parts) >= 2:
width_value = width_value if width_value not in _EMPTY_SEQUENCE else parts[0]
height_value = height_value if height_value not in _EMPTY_SEQUENCE else parts[1]
elif isinstance(size_value, (list, tuple)) and len(size_value) >= 2:
width_value = width_value if width_value not in _EMPTY_SEQUENCE else size_value[0]
height_value = height_value if height_value not in _EMPTY_SEQUENCE else size_value[1]
width_int = _to_int(width_value)
height_int = _to_int(height_value)
if width_int and height_int:
params.append(f"Size: {width_int}x{height_int}")
base_model = _ensure_dict(generation.get("base_model"))
if not base_model:
base_model = _ensure_dict(ai_info.get("base_model"))
if not base_model:
base_model = _ensure_dict(root_metadata.get("base_model"))
model_name = _pick_value(
(base_model, "name"),
(base_model, "model"),
(base_model, "unet"),
(base_model, "ckpt_name"),
(generation, "model"),
(root_metadata, "model"),
)
if model_name not in _EMPTY_SEQUENCE:
params.append(f"Model: {model_name}")
model_hash = _pick_value(
(base_model, "hash"),
(base_model, "model_hash"),
(base_model, "sha256"),
(base_model, "checksum"),
(base_model, "short_hash"),
(base_model, "model_hash_sha256"),
(generation, "model_hash"),
(generation, "hash"),
(generation, "checksum"),
(root_metadata, "model_hash"),
)
if model_hash not in _EMPTY_SEQUENCE:
params.append(f"Model hash: {model_hash}")
modules = _ensure_dict(generation.get("modules"))
metadata_modules = _ensure_dict(root_metadata.get("modules"))
ai_assets = _ensure_dict(ai_info.get("assets"))
metadata_assets = _ensure_dict(root_metadata.get("assets"))
generation_assets = _ensure_dict(generation.get("assets"))
lora_sources = _iter_lora_sources(
generation.get("loras"),
modules,
metadata_modules,
generation_assets,
metadata_assets,
ai_assets,
root_metadata.get("loras"),
)
lora_entries = _collect_lora_entries(lora_sources)
if lora_entries:
seen = set()
formatted_loras: List[str] = []
for lora in lora_entries:
name = _clean_name(
lora.get("name")
or lora.get("alias")
or lora.get("title")
or lora.get("id")
)
if not name:
continue
raw_model_strength = _pick_value(
(lora, "strength_model"),
(lora, "strength"),
(lora, "weight"),
)
raw_clip_strength = _pick_value(
(lora, "strength_clip"),
(lora, "clip_strength"),
)
strength_model_value = _coerce_float(raw_model_strength, 1.0)
if raw_model_strength in _EMPTY_SEQUENCE and raw_clip_strength not in _EMPTY_SEQUENCE:
strength_model_value = _coerce_float(raw_clip_strength, strength_model_value)
strength_clip_value = _coerce_float(raw_clip_strength, strength_model_value)
formatted_model = strength_formatter(strength_model_value)
formatted_clip = strength_formatter(strength_clip_value)
key = (name.lower(), formatted_model, formatted_clip)
if key in seen:
continue
seen.add(key)
formatted_loras.append(f"{name} ({formatted_model}, {formatted_clip})")
if formatted_loras:
params.append(f"LoRA: {'; '.join(formatted_loras)}")
has_prompt = bool(positive_prompt or negative_prompt)
if params:
params.append(METADATA_SYSTEM_GENERATED_BY)
elif has_prompt:
params.append(METADATA_SYSTEM_GENERATED_BY)
lines: List[str] = []
if positive_prompt:
lines.append(positive_prompt)
if negative_prompt:
lines.append(f"Negative prompt: {negative_prompt}")
if params:
lines.append(", ".join(params))
if not lines:
has_ai_section = isinstance(metadata, dict) and metadata.get("ai_info") is not None
return METADATA_SYSTEM_GENERATED_BY if has_ai_section else ""
return "\n".join(lines).strip()
__all__ = ["generate_a1111_parameters", "METADATA_SYSTEM_GENERATED_BY"]