Files
EricRollei-Local_LLM_Prompt…/core/profiles/loader.py
T
Eric HissandClaude Fable 5.1 4459af9ea6 V2.0.0: wildcard engine, platform profiles, MiniMax H3 default, API models
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
2026-09-21 00:28:58 -07:00

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]