Files
EricRollei-Local_LLM_Prompt…/wildcard_support.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

234 lines
9.1 KiB
Python

"""
Wildcard support shared by every enhancer node.
One place for: the two optional inputs every node appends, the seed convention,
the IS_CHANGED rule, the prompt-database provider, and the call into core/wildcards.
Where wildcard packs live (highest priority first; the first folder that has a key wins):
1. a node's extra_paths input
2. ComfyUI/models/wildcards the shared home for packs - drop them here
3. any `wildcards:` entry in extra_model_paths.yaml, e.g. A:/Models/wildcards
4. "wildcard_paths" in this package's config.json
5. this package's own wildcards/ folder the lists that ship with the nodes
Seed convention (wildcard_seed input):
-1 fresh roll on every run (the node is never cached by ComfyUI)
>= 0 reproducible: same seed + same wildcard files = same picks
Author: Eric Hiss (GitHub: EricRollei)
License: Dual License (Non-Commercial and Commercial Use). See LICENSE.
"""
import os
import random
import time
from typing import Any, Dict, Iterable, List, Optional, Tuple
from .core.wildcards import Resolution, WildcardEngine, get_engine
DB_CANDIDATE_LIMIT = 200
WILDCARD_SEED_INPUT = ("INT", {
"default": -1,
"min": -1,
"max": 0x7FFFFFFF,
"tooltip": (
"Seed for wildcards: __name__, {a|b|c}, __objects[tag AND tag]__.\n"
"-1 = fresh roll every run. 0 or more = reproducible picks."
),
})
WILDCARD_LLM_BLOCK_INPUT = ("BOOLEAN", {
"default": True,
"tooltip": (
"Tell the LLM which elements were picked at random so it keeps them:\n"
"concepts keep their meaning, __=verbatim__ picks stay word for word.\n"
"Off = the LLM only sees the resolved text."
),
})
WILDCARD_INSTRUCTIONS_INPUT = ("STRING", {
"forceInput": True,
"tooltip": (
"Connect the llm_instructions output of a Wildcard Processor or Semantic Variation node here\n"
"when the wildcards were resolved before this node. The LLM is then told which elements were\n"
"picked at random, and verbatim picks are checked in its answer, exactly as if this node had\n"
"resolved them itself."
),
})
def comfy_wildcard_dirs() -> List[str]:
"""
ComfyUI's wildcard folders: models/wildcards first, then whatever extra_model_paths.yaml
registers under `wildcards:`. models/wildcards is registered with folder_paths under the
name "wildcards" so other tools that ask ComfyUI for that folder find the same place.
Only folders that exist are returned. Outside ComfyUI this returns [].
"""
try:
import folder_paths
except ImportError:
return []
default_dir = os.path.join(folder_paths.models_dir, "wildcards")
try:
folder_paths.add_model_folder_path("wildcards", default_dir)
except Exception:
pass
ordered: List[str] = [default_dir]
try:
ordered.extend(folder_paths.get_folder_paths("wildcards"))
except Exception:
pass
existing: List[str] = []
for path in ordered:
absolute = os.path.abspath(path)
if os.path.isdir(absolute) and absolute not in existing:
existing.append(absolute)
return existing
def get_wildcard_engine(extra_paths: Optional[Iterable[str]] = None) -> WildcardEngine:
"""The engine every node uses: node extra paths, then ComfyUI's wildcard folders, config, shipped lists."""
return get_engine(extra_paths, host_paths=comfy_wildcard_dirs())
def wildcard_optional_inputs() -> Dict[str, Any]:
"""The inputs a node appends at the END of its 'optional' section, in this order."""
return {
"wildcard_seed": WILDCARD_SEED_INPUT,
"wildcard_llm_block": WILDCARD_LLM_BLOCK_INPUT,
"wildcard_instructions": WILDCARD_INSTRUCTIONS_INPUT,
}
def normalise_seed(wildcard_seed: Optional[int], offset: int = 0) -> Optional[int]:
"""-1 / None -> None (fresh roll). Otherwise seed + offset, so variation N differs from variation 0."""
if wildcard_seed is None:
return None
try:
value = int(wildcard_seed)
except (TypeError, ValueError):
return None
if value < 0:
return None
return value + int(offset)
def is_changed_token(wildcard_seed: Optional[int]) -> Any:
"""
Value for a node's IS_CHANGED. A fresh-roll seed must defeat ComfyUI's cache, or the
wildcards would never re-roll while the other inputs stay the same.
"""
if normalise_seed(wildcard_seed) is None:
return float(time.time())
return int(wildcard_seed)
def database_provider(argument: str, text_so_far: str, rng: random.Random) -> Optional[str]:
"""
__db:query__ -> one prompt from the prompt database matching the FTS query.
Candidates come back in a stable order and are picked with the run's own RNG, so the
draw is reproducible for a given seed (SQLite's RANDOM() would not be).
"""
from .prompt_database import PromptDatabase
results, _total = PromptDatabase().search(query=argument, limit=DB_CANDIDATE_LIMIT, order_by="oldest")
texts = [row.get("prompt_text", "").strip() for row in results if row.get("prompt_text")]
if not texts:
return None
return rng.choice(texts)
def resolve_wildcards(text: str, seed: Optional[int] = None, legacy: bool = True,
upstream_instructions: str = "") -> Resolution:
"""
Resolve wildcard syntax in `text`. Never raises: on any failure the text comes back
unchanged with a warning, so a prompt problem cannot break a workflow.
upstream_instructions: the llm_instructions text of a Wildcard Processor or Semantic Variation
node that resolved wildcards earlier in the graph. It travels on the Resolution, so llm_block()
and enforce_verbatim() treat those picks like this node's own.
"""
try:
resolution = get_wildcard_engine().process(
text or "",
seed=seed,
legacy=legacy,
providers={"db": database_provider},
)
except Exception as error: # pragma: no cover - defensive
resolution = Resolution(text=text or "", seed=seed)
resolution.warnings.append(f"wildcard engine failed, text passed through: {error}")
resolution.upstream_instructions = (upstream_instructions or "").strip()
return resolution
def items_from_instructions(instructions: str, heading: str) -> List[str]:
"""The bullet items listed under one heading ('Concepts', 'Verbatim', 'Inspiration') of an llm_instructions block."""
found: List[str] = []
inside = False
for line in (instructions or "").splitlines():
stripped = line.strip()
if not stripped.startswith("- "):
inside = stripped.lower().startswith(heading.lower())
continue
if inside:
value = stripped[2:].strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
value = value[1:-1]
if value and value not in found:
found.append(value)
return found
def verbatim_from_instructions(instructions: str) -> List[str]:
"""The quoted strings listed under the 'Verbatim' heading of an llm_instructions block."""
return items_from_instructions(instructions, "Verbatim")
def concepts_from_instructions(instructions: str) -> List[str]:
"""The phrases listed under the 'Concepts' heading of an llm_instructions block."""
return items_from_instructions(instructions, "Concepts")
def llm_block(resolution: Optional[Resolution], enabled: bool = True) -> str:
"""The selected-elements instruction block, or '' when disabled or nothing was picked."""
if not enabled or resolution is None:
return ""
blocks = [resolution.format_for_llm(), getattr(resolution, "upstream_instructions", "")]
return "\n\n".join(block for block in blocks if block)
def enforce_verbatim(text: str, resolution: Optional[Resolution]) -> Tuple[str, List[str]]:
"""
Survival check for verbatim picks (__=name__, {=a|b}): LoRA triggers and names must be in
the LLM's output word for word. Any that went missing are appended, the same way the nodes
already treat missing positive keywords. Returns (text, list of strings that had to be added).
"""
if resolution is None or not text:
return text, []
lowered = text.lower()
wanted = [slot.value for slot in resolution.slots_by_binding("verbatim")]
for value in verbatim_from_instructions(getattr(resolution, "upstream_instructions", "")):
if value not in wanted:
wanted.append(value)
missing = [value for value in wanted if value.lower() not in lowered]
if missing:
text = f"{text.rstrip()} {', '.join(missing)}"
return text, missing
def report_lines(resolution: Optional[Resolution]) -> str:
"""Short text for a node's status / breakdown output. '' when no wildcard was used."""
if resolution is None:
return ""
upstream = "Wildcards resolved upstream: instructions received." if getattr(resolution, "upstream_instructions", "") else ""
if not (resolution.top_level_slots() or resolution.warnings):
return upstream
own = "Wildcards: " + resolution.summary()
return f"{own}\n{upstream}" if upstream else own