Every node id, input name and existing widget position is unchanged, so saved
workflows load; new inputs are appended only.
Wildcards
- core/wildcards: Dynamic Prompts / Impact Pack grammar, tag queries, variables,
conditionals, concept / verbatim / loose bindings, reproducible seeds.
Wired into every enhancer node; reads ComfyUI/models/wildcards.
- New nodes: Wildcard Processor, Semantic Variation (direction axes),
Concept Survival Check. Starter lists in wildcards/.
- Random choices use a private generator; global random state is untouched.
Platform profiles
- core/profiles + profiles/*.yaml: one file per model (format, length,
negative prompt or none, quality tokens, text and dialogue syntax, vendor
rewriter prompt fetched once and cached). profiles_user/ overrides by id.
- New node: Platform Prompt Rewriter (prose, tags, edit instructions, JSON,
MiniMax H3 fields, Hailuo brackets), with validators for each format.
- Text-to-Image and Image-to-Image read profiles through platforms.py;
corrected facts for FLUX, Kontext, Chroma, Wan, AuraFlow, Kolors, NoobAI,
Qwen-Image, HunyuanImage. Negative prompt is empty for models without one.
- Hosted / API models: Seedream 5, Nano Banana, GPT Image, Midjourney V8,
FLUX.2 pro, Ideogram 3, Recraft V4, Seedance 2.0 / 2.5, Kling 3.0, Veo 3.1,
Gemini Omni Flash, Runway, Wan 3.0, Luma, hosted H3, Grok, Vidu, PixVerse.
Reports say how to switch the vendor's own prompt rewriting off.
- Illustrious: spaced tags, quality tags last, 25-80 words (OnomaAI paper).
Video nodes
- target_model and duration_seconds appended to the three video expanders.
Default target is MiniMax H3; wan22_video is the original path, unchanged.
A workflow saved earlier loads with the default until the input is set.
Also included: the Alice, Ideogram Magic Prompt and prompt-database nodes that
were already registered in __init__.py but had not been committed.
Fixes: v1 {animal} / {label:a|b} wildcards were inert in the video nodes;
Image-to-Video save_to_file raised NameError; T2I padded short-prompt models.
.gitattributes added (text=auto) so CRLF working files stop showing as
whole-file changes. prompt_database.db and the cached Ideogram prompt are
git-ignored.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VpCi6nw5UECMdZ7ayYJvN
196 lines
7.7 KiB
Python
196 lines
7.7 KiB
Python
"""
|
|
ProfileLibrary: reads platform profiles (*.yaml) from one or more folders.
|
|
|
|
Search folders, highest priority first. When an id exists in several folders the first folder
|
|
wins, so a copy in your own folder replaces the shipped profile without editing the package:
|
|
1. extra paths passed by the caller
|
|
2. "profile_paths" in <package>/config.json
|
|
3. <package>/profiles_user/ your own and your edited profiles (not shipped, not overwritten by updates)
|
|
4. <package>/profiles/ profiles that ship with the package
|
|
|
|
Files whose name starts with "_" are skipped (notes, templates). A broken file never stops the
|
|
others from loading; the problem is kept in .warnings. Files are re-read when their mtime changes.
|
|
|
|
`extends: other_id` inherits from another profile: dictionaries merge key by key, everything
|
|
else (strings, numbers, lists) is replaced by the child's value.
|
|
|
|
(c) Eric Hiss. Dual license, see LICENSE.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
|
|
from .schema import Profile, profile_from_dict
|
|
|
|
PACKAGE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
BUILTIN_PROFILE_DIR = os.path.join(PACKAGE_DIR, "profiles")
|
|
USER_PROFILE_DIR = os.path.join(PACKAGE_DIR, "profiles_user")
|
|
CONFIG_PATH = os.path.join(PACKAGE_DIR, "config.json")
|
|
|
|
MAX_EXTENDS_DEPTH = 5
|
|
|
|
|
|
def configured_paths() -> List[str]:
|
|
"""Extra profile folders from config.json -> "profile_paths": ["D:/my_profiles", ...]."""
|
|
try:
|
|
with open(CONFIG_PATH, "r", encoding="utf-8") as handle:
|
|
paths = json.load(handle).get("profile_paths", [])
|
|
except (OSError, ValueError):
|
|
return []
|
|
if isinstance(paths, str):
|
|
paths = [paths]
|
|
return [str(path) for path in paths if path]
|
|
|
|
|
|
def _deep_merge(parent: Dict[str, Any], child: Dict[str, Any]) -> Dict[str, Any]:
|
|
merged = dict(parent)
|
|
for key, value in child.items():
|
|
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
merged[key] = _deep_merge(merged[key], value)
|
|
else:
|
|
merged[key] = value
|
|
return merged
|
|
|
|
|
|
def _read_yaml(path: str) -> Any:
|
|
import yaml # PyYAML ships with ComfyUI
|
|
with open(path, "r", encoding="utf-8-sig") as handle:
|
|
return yaml.safe_load(handle)
|
|
|
|
|
|
class ProfileLibrary:
|
|
def __init__(self, search_paths: Iterable[str]):
|
|
self.search_paths: List[str] = []
|
|
for path in search_paths:
|
|
absolute = os.path.abspath(path)
|
|
if absolute not in self.search_paths:
|
|
self.search_paths.append(absolute)
|
|
self.warnings: List[str] = []
|
|
self._profiles: Dict[str, Profile] = {}
|
|
self._order: List[str] = []
|
|
self._stamp: Tuple[Tuple[str, float], ...] = ()
|
|
self.shadowed: Dict[str, List[str]] = {} # id -> files that lost to an earlier folder
|
|
|
|
# -- scanning ----------------------------------------------------------------------
|
|
def _files(self) -> List[str]:
|
|
found: List[str] = []
|
|
for folder in self.search_paths:
|
|
if not os.path.isdir(folder):
|
|
continue
|
|
for root, dirs, names in os.walk(folder):
|
|
dirs.sort()
|
|
for name in sorted(names):
|
|
if name.startswith("_") or not name.lower().endswith((".yaml", ".yml")):
|
|
continue
|
|
found.append(os.path.join(root, name))
|
|
return found
|
|
|
|
def _current_stamp(self, files: List[str]) -> Tuple[Tuple[str, float], ...]:
|
|
stamp = []
|
|
for path in files:
|
|
try:
|
|
stamp.append((path, os.path.getmtime(path)))
|
|
except OSError:
|
|
continue
|
|
return tuple(stamp)
|
|
|
|
def refresh(self, force: bool = False) -> None:
|
|
files = self._files()
|
|
stamp = self._current_stamp(files)
|
|
if not force and stamp == self._stamp and self._profiles:
|
|
return
|
|
|
|
self.warnings = []
|
|
self.shadowed = {}
|
|
raw: Dict[str, Dict[str, Any]] = {}
|
|
origin: Dict[str, str] = {}
|
|
order: List[str] = []
|
|
|
|
for path in files:
|
|
try:
|
|
data = _read_yaml(path)
|
|
except Exception as error:
|
|
self.warnings.append(f"{path}: could not be read as YAML ({error})")
|
|
continue
|
|
documents = data if isinstance(data, list) else [data]
|
|
for document in documents:
|
|
if not isinstance(document, dict) or not document.get("id"):
|
|
self.warnings.append(f"{path}: no 'id' key, skipped")
|
|
continue
|
|
profile_id = str(document["id"])
|
|
if profile_id in raw:
|
|
self.shadowed.setdefault(profile_id, []).append(path)
|
|
continue
|
|
raw[profile_id] = document
|
|
origin[profile_id] = path
|
|
order.append(profile_id)
|
|
|
|
profiles: Dict[str, Profile] = {}
|
|
for profile_id in order:
|
|
data = self._resolve_extends(profile_id, raw, depth=0)
|
|
if data is None:
|
|
continue
|
|
profile, problems = profile_from_dict(data, origin=origin[profile_id])
|
|
self.warnings.extend(problems)
|
|
if profile is not None:
|
|
profiles[profile_id] = profile
|
|
|
|
self._profiles = profiles
|
|
self._order = [profile_id for profile_id in order if profile_id in profiles]
|
|
self._stamp = stamp
|
|
|
|
def _resolve_extends(self, profile_id: str, raw: Dict[str, Dict[str, Any]], depth: int) -> Optional[Dict[str, Any]]:
|
|
data = raw[profile_id]
|
|
parent_id = data.get("extends")
|
|
if not parent_id:
|
|
return data
|
|
if depth >= MAX_EXTENDS_DEPTH:
|
|
self.warnings.append(f"{profile_id}: 'extends' chain deeper than {MAX_EXTENDS_DEPTH}, profile skipped")
|
|
return None
|
|
if parent_id not in raw:
|
|
self.warnings.append(f"{profile_id}: extends unknown profile '{parent_id}', profile skipped")
|
|
return None
|
|
parent = self._resolve_extends(str(parent_id), raw, depth + 1)
|
|
if parent is None:
|
|
return None
|
|
inherited = {key: value for key, value in parent.items() if key not in ("hidden",)}
|
|
return _deep_merge(inherited, data)
|
|
|
|
# -- lookups ------------------------------------------------------------------------
|
|
def get(self, profile_id: str) -> Optional[Profile]:
|
|
self.refresh()
|
|
return self._profiles.get(profile_id)
|
|
|
|
def all(self, modality: Optional[str] = None, task: Optional[str] = None, include_hidden: bool = False) -> List[Profile]:
|
|
self.refresh()
|
|
selected: List[Profile] = []
|
|
for profile_id in self._order:
|
|
profile = self._profiles[profile_id]
|
|
if profile.hidden and not include_hidden:
|
|
continue
|
|
if modality and profile.modality != modality:
|
|
continue
|
|
if task and task not in profile.tasks:
|
|
continue
|
|
selected.append(profile)
|
|
return selected
|
|
|
|
def ids(self, modality: Optional[str] = None, task: Optional[str] = None) -> List[str]:
|
|
return [profile.id for profile in self.all(modality=modality, task=task)]
|
|
|
|
|
|
_LIBRARIES: Dict[Tuple[str, ...], ProfileLibrary] = {}
|
|
|
|
|
|
def get_library(extra_paths: Optional[Iterable[str]] = None) -> ProfileLibrary:
|
|
"""Shared library for the given extra paths (config, user and shipped folders are always included)."""
|
|
paths = [os.path.abspath(path) for path in (extra_paths or []) if path]
|
|
paths += configured_paths() + [USER_PROFILE_DIR, BUILTIN_PROFILE_DIR]
|
|
key = tuple(paths)
|
|
if key not in _LIBRARIES:
|
|
_LIBRARIES[key] = ProfileLibrary(paths)
|
|
return _LIBRARIES[key]
|