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
297 lines
15 KiB
Python
297 lines
15 KiB
Python
"""
|
|
Platform profile: everything the rewriter needs to know about one target model, as data.
|
|
|
|
A profile is one YAML file in profiles/. Adding a platform means adding a file, not editing a node.
|
|
Every field below is optional in the file except id, name, modality, tasks and caption_style;
|
|
missing fields take the defaults shown here.
|
|
|
|
Field reference (the YAML keys are the attribute names):
|
|
|
|
id stable key, used as the dropdown value. Never rename one that has shipped.
|
|
name display name
|
|
developer who makes the model
|
|
summary one line: what the model is and how it likes to be prompted
|
|
modality image | edit | video (audio, music, speech arrive in Phase 4)
|
|
tasks which of these the model does: t2i, edit, t2v, i2v, flf2v, ref2v
|
|
encoder text encoder, as a fact for the notes ("umT5-XXL", "Qwen3-VL-4B")
|
|
encoder_family clip | t5 | llm (decides whether keyword soup or prose works)
|
|
caption_style prose | tags | tags_prose | instruction | json | fields | bracket_prose
|
|
languages prompt languages the model was trained on, best first
|
|
target_words [low, high] word range the rewriter aims for
|
|
max_tokens hard token limit of the text encoder (0 = not documented)
|
|
max_chars hard character limit of a hosted API (0 = none)
|
|
negative yes | no | ignored (ignored = distilled, CFG off, field has no effect)
|
|
negative_default the negative prompt to output when negative == yes
|
|
negative_note when the answer depends on the checkpoint (base vs turbo)
|
|
quality_tokens forbid | neutral | help | required
|
|
quality_token_list the tokens, where they help
|
|
prefix text the prompt must start with (score tags, quality tags)
|
|
quoted_text double | single | none how text to be rendered is marked
|
|
weights true when (word:1.2) syntax is understood by the usual UI for this model
|
|
hex_colors true when #RRGGBB codes are understood
|
|
audio true when the model generates sound from the prompt
|
|
structure the vendor's prompt formula, one or two lines
|
|
rules do's, as imperative sentences for the LLM
|
|
avoid don'ts
|
|
camera camera vocabulary the model is known to respond to
|
|
task_rules per-task overrides: {i2v: {target_words, first_line, structure, rules, avoid, output_template}}
|
|
output_template literal skeleton for fields / json styles
|
|
validator name of a check in validate.py run on the LLM's answer
|
|
official vendor-published rewriter prompts: list of {task, url, format, variable, language, drop_lines, preamble, note}
|
|
tailoring short additive text appended AFTER an official prompt. Keep it small and log why in PLATFORM_NOTES.md.
|
|
sources [{url, supports, confidence}]
|
|
checked date the facts were last verified
|
|
confidence high | medium | low (how good the sources are)
|
|
extends id of a profile to inherit from (deep merge; lists are replaced, not appended)
|
|
legacy values handed to the older nodes through platforms.py (prompt_style, length_guidance, ...)
|
|
hidden true = loadable by id but not offered in dropdowns
|
|
|
|
(c) Eric Hiss. Dual license, see LICENSE.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
MODALITIES = ("image", "edit", "video")
|
|
TASKS = ("t2i", "edit", "t2v", "i2v", "flf2v", "ref2v")
|
|
CAPTION_STYLES = ("prose", "tags", "tags_prose", "instruction", "json", "fields", "bracket_prose")
|
|
ENCODER_FAMILIES = ("clip", "t5", "llm", "")
|
|
# yes = a separate negative prompt works | no = none, and naming unwanted things backfires | ignored = distilled, no CFG
|
|
# inline = no separate field, but the vendor endorses short exclusions written into the prompt (GPT Image, Seedance, Kling)
|
|
NEGATIVE_MODES = ("yes", "no", "ignored", "inline")
|
|
QUALITY_MODES = ("forbid", "neutral", "help", "required")
|
|
QUOTE_MODES = ("double", "single", "none")
|
|
OFFICIAL_FORMATS = ("text", "python_var", "sections")
|
|
|
|
REQUIRED_KEYS = ("id", "name", "modality", "tasks", "caption_style")
|
|
|
|
|
|
@dataclass
|
|
class OfficialPrompt:
|
|
task: str # which task this prompt rewrites for; "*" = every task
|
|
url: str
|
|
format: str = "text" # text: whole file | python_var: a string assigned in a .py | sections: [SYSTEM]/[USER] file
|
|
variable: str = "" # python_var: the variable name; sections: the section name (default "system")
|
|
language: str = "en" # language the INSTRUCTIONS are written in
|
|
drop_lines: List[str] = field(default_factory=list) # regexes; matching lines are removed, and that is logged
|
|
preamble: str = "" # placed BEFORE the vendor text; for files that are writing guides rather than system prompts
|
|
note: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Profile:
|
|
id: str
|
|
name: str
|
|
modality: str
|
|
tasks: List[str]
|
|
caption_style: str
|
|
developer: str = ""
|
|
summary: str = ""
|
|
encoder: str = ""
|
|
encoder_family: str = ""
|
|
languages: List[str] = field(default_factory=lambda: ["en"])
|
|
target_words: Tuple[int, int] = (60, 150)
|
|
words_per_second: Tuple[int, int] = (0, 0) # video: words of prompt per second of clip; (0, 0) = not known
|
|
max_tokens: int = 0
|
|
max_chars: int = 0
|
|
negative: str = "no"
|
|
negative_default: str = ""
|
|
negative_note: str = ""
|
|
quality_tokens: str = "neutral"
|
|
quality_token_list: List[str] = field(default_factory=list)
|
|
prefix: str = ""
|
|
suffix: str = "" # quality tags that belong at the END of the prompt (Illustrious); same idea as prefix
|
|
quoted_text: str = "double"
|
|
weights: bool = False
|
|
hex_colors: bool = False
|
|
audio: bool = False
|
|
structure: str = ""
|
|
rules: List[str] = field(default_factory=list)
|
|
avoid: List[str] = field(default_factory=list)
|
|
camera: List[str] = field(default_factory=list)
|
|
task_rules: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
output_template: str = ""
|
|
validator: str = ""
|
|
official: List[OfficialPrompt] = field(default_factory=list)
|
|
tailoring: str = ""
|
|
api_note: str = "" # hosted models: what to set in the API call (vendor rewriting off, which field takes the prompt)
|
|
sources: List[Dict[str, str]] = field(default_factory=list)
|
|
checked: str = ""
|
|
confidence: str = "medium"
|
|
extends: str = ""
|
|
legacy: Dict[str, Any] = field(default_factory=dict)
|
|
hidden: bool = False
|
|
origin: str = "" # file the profile was read from
|
|
|
|
# -- task-aware accessors ------------------------------------------------------------
|
|
def default_task(self) -> str:
|
|
return self.tasks[0] if self.tasks else "t2i"
|
|
|
|
def resolve_task(self, task: Optional[str]) -> str:
|
|
"""'auto' / unknown / unsupported -> the profile's first task."""
|
|
if task and task in self.tasks:
|
|
return task
|
|
return self.default_task()
|
|
|
|
def for_task(self, task: str, key: str, default: Any = None) -> Any:
|
|
"""A per-task override if task_rules has one, else the profile-level value."""
|
|
override = self.task_rules.get(task, {})
|
|
if key in override and override[key] not in (None, "", []):
|
|
return override[key]
|
|
value = getattr(self, key, default)
|
|
return default if value in (None, "") else value
|
|
|
|
def words_for(self, task: str) -> Tuple[int, int]:
|
|
low, high = self.for_task(task, "target_words", self.target_words)
|
|
return int(low), int(high)
|
|
|
|
def words_for_run(self, task: str, duration_seconds: float = 0.0,
|
|
override: Optional[Tuple[int, int]] = None) -> Tuple[int, int]:
|
|
"""
|
|
The length target for one run. An explicit override wins; else, for a video profile that states
|
|
words_per_second, the clip length decides (never above the profile's upper bound); else words_for.
|
|
"""
|
|
if override and override[1] > 0:
|
|
return int(override[0]), int(override[1])
|
|
low, high = self.words_for(task)
|
|
per_low, per_high = self.words_per_second
|
|
if duration_seconds and per_high > 0:
|
|
run_high = min(high, int(per_high * duration_seconds))
|
|
run_low = int(per_low * duration_seconds)
|
|
if run_low >= run_high: # a long clip that hits the upper bound
|
|
run_low = int(run_high * 0.8)
|
|
return max(1, run_low), max(2, run_high)
|
|
return low, high
|
|
|
|
def official_for(self, task: str) -> Optional[OfficialPrompt]:
|
|
for entry in self.official:
|
|
if entry.task == task:
|
|
return entry
|
|
for entry in self.official:
|
|
if entry.task == "*":
|
|
return entry
|
|
return None
|
|
|
|
|
|
def _as_list(value: Any) -> List[Any]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, (list, tuple)):
|
|
return list(value)
|
|
return [value]
|
|
|
|
|
|
def _word_range(value: Any, fallback: Tuple[int, int]) -> Tuple[int, int]:
|
|
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
low, high = int(value[0]), int(value[1])
|
|
return (min(low, high), max(low, high))
|
|
if isinstance(value, (int, float)) and value > 0:
|
|
return (max(1, int(value * 0.6)), int(value))
|
|
return fallback
|
|
|
|
|
|
def profile_from_dict(data: Dict[str, Any], origin: str = "") -> Tuple[Optional[Profile], List[str]]:
|
|
"""Build a Profile from parsed YAML. Returns (profile or None, problems). Never raises on bad data."""
|
|
problems: List[str] = []
|
|
label = data.get("id") or origin or "profile"
|
|
|
|
missing = [key for key in REQUIRED_KEYS if not data.get(key)]
|
|
if missing:
|
|
return None, [f"{label}: missing required key(s): {', '.join(missing)}"]
|
|
|
|
def checked_enum(key: str, allowed: Tuple[str, ...], default: str) -> str:
|
|
raw_value = data.get(key, default)
|
|
if isinstance(raw_value, bool): # YAML reads a bare yes / no as a boolean
|
|
raw_value = "yes" if raw_value else "no"
|
|
value = str(raw_value or default)
|
|
if value not in allowed:
|
|
problems.append(f"{label}: {key}='{value}' is not one of {', '.join(a for a in allowed if a)}; using '{default}'")
|
|
return default
|
|
return value
|
|
|
|
tasks = [str(task) for task in _as_list(data.get("tasks"))]
|
|
unknown_tasks = [task for task in tasks if task not in TASKS]
|
|
if unknown_tasks:
|
|
problems.append(f"{label}: unknown task(s) {', '.join(unknown_tasks)} ignored")
|
|
tasks = [task for task in tasks if task in TASKS]
|
|
if not tasks:
|
|
return None, problems + [f"{label}: no valid task left"]
|
|
|
|
official: List[OfficialPrompt] = []
|
|
for entry in _as_list(data.get("official")):
|
|
if not isinstance(entry, dict) or not entry.get("url"):
|
|
problems.append(f"{label}: an 'official' entry has no url and was skipped")
|
|
continue
|
|
prompt_format = str(entry.get("format", "text"))
|
|
if prompt_format not in OFFICIAL_FORMATS:
|
|
problems.append(f"{label}: official format '{prompt_format}' unknown, entry skipped")
|
|
continue
|
|
official.append(OfficialPrompt(
|
|
task=str(entry.get("task", "*")),
|
|
url=str(entry["url"]),
|
|
format=prompt_format,
|
|
variable=str(entry.get("variable", "")),
|
|
language=str(entry.get("language", "en")),
|
|
drop_lines=[str(pattern) for pattern in _as_list(entry.get("drop_lines"))],
|
|
preamble=str(entry.get("preamble", "")).strip(),
|
|
note=str(entry.get("note", "")),
|
|
))
|
|
|
|
task_rules: Dict[str, Dict[str, Any]] = {}
|
|
for task, overrides in (data.get("task_rules") or {}).items():
|
|
if task not in TASKS or not isinstance(overrides, dict):
|
|
problems.append(f"{label}: task_rules entry '{task}' ignored")
|
|
continue
|
|
cleaned = dict(overrides)
|
|
if "target_words" in cleaned:
|
|
cleaned["target_words"] = _word_range(cleaned["target_words"], (60, 150))
|
|
task_rules[task] = cleaned
|
|
|
|
profile = Profile(
|
|
id=str(data["id"]),
|
|
name=str(data["name"]),
|
|
modality=checked_enum("modality", MODALITIES, "image"),
|
|
tasks=tasks,
|
|
caption_style=checked_enum("caption_style", CAPTION_STYLES, "prose"),
|
|
developer=str(data.get("developer", "")),
|
|
summary=str(data.get("summary", "")).strip(),
|
|
encoder=str(data.get("encoder", "")),
|
|
encoder_family=checked_enum("encoder_family", ENCODER_FAMILIES, ""),
|
|
languages=[str(item) for item in _as_list(data.get("languages"))] or ["en"],
|
|
target_words=_word_range(data.get("target_words"), (60, 150)),
|
|
words_per_second=_word_range(data.get("words_per_second"), (0, 0)),
|
|
max_tokens=int(data.get("max_tokens", 0) or 0),
|
|
max_chars=int(data.get("max_chars", 0) or 0),
|
|
negative=checked_enum("negative", NEGATIVE_MODES, "no"),
|
|
negative_default=str(data.get("negative_default", "")).strip(),
|
|
negative_note=str(data.get("negative_note", "")).strip(),
|
|
quality_tokens=checked_enum("quality_tokens", QUALITY_MODES, "neutral"),
|
|
quality_token_list=[str(item) for item in _as_list(data.get("quality_token_list"))],
|
|
prefix=str(data.get("prefix", "")).strip(),
|
|
suffix=str(data.get("suffix", "")).strip(),
|
|
quoted_text=checked_enum("quoted_text", QUOTE_MODES, "double"),
|
|
weights=bool(data.get("weights", False)),
|
|
hex_colors=bool(data.get("hex_colors", False)),
|
|
audio=bool(data.get("audio", False)),
|
|
structure=str(data.get("structure", "")).strip(),
|
|
rules=[str(item) for item in _as_list(data.get("rules"))],
|
|
avoid=[str(item) for item in _as_list(data.get("avoid"))],
|
|
camera=[str(item) for item in _as_list(data.get("camera"))],
|
|
task_rules=task_rules,
|
|
output_template=str(data.get("output_template", "")).rstrip(),
|
|
validator=str(data.get("validator", "")),
|
|
official=official,
|
|
tailoring=str(data.get("tailoring", "")).strip(),
|
|
api_note=str(data.get("api_note", "")).strip(),
|
|
sources=[dict(item) for item in _as_list(data.get("sources")) if isinstance(item, dict)],
|
|
checked=str(data.get("checked", "")),
|
|
confidence=str(data.get("confidence", "medium")),
|
|
extends=str(data.get("extends", "")),
|
|
legacy=dict(data.get("legacy") or {}),
|
|
hidden=bool(data.get("hidden", False)),
|
|
origin=origin,
|
|
)
|
|
return profile, problems
|