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

465 lines
18 KiB
Python

"""
Main ComfyUI Node for AI Video Prompt Expansion - VERSION 1.2.1
Fixed auto mode + shows detected tier
"""
import os
import re
import random
from typing import Tuple
from .llm_backend import LLMBackend
from .expansion_engine import PromptExpander
from .utils import (
save_prompts_to_file,
parse_keywords,
format_breakdown,
validate_positive_keywords
)
from .wildcard_support import (
enforce_verbatim,
is_changed_token,
normalise_seed,
wildcard_optional_inputs,
)
from .profile_support import WAN_TARGET, add_required_terms, target_report_lines, video_target_inputs
class AIVideoPromptExpander:
"""
ComfyUI node that expands simple video ideas into detailed prompts
using local LLMs (LM Studio or Ollama)
"""
def __init__(self):
self.expander = PromptExpander()
self.type = "prompt_expansion"
self.output_dir = "output/video_prompts"
self._emphasis_store = [] # Store for emphasis syntax preservation
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# Core inputs
"basic_prompt": ("STRING", {
"multiline": True,
"default": "A cat playing piano in a cozy room"
}),
"preset": ([
"custom",
"cinematic",
"surreal",
"action",
"stylized",
"noir",
"random"
], {
"default": "cinematic"
}),
"expansion_tier": ([
"auto",
"basic",
"enhanced",
"advanced",
"cinematic"
], {
"default": "auto"
}),
"mode": ([
"text-to-video",
"image-to-video"
], {
"default": "text-to-video"
}),
# LLM Configuration
"llm_backend": ([
"lm_studio",
"ollama",
"qwen3_vl"
], {
"default": "lm_studio",
"tooltip": (
"lm_studio: Uses currently loaded model in LM Studio\n"
"ollama: Uses currently loaded model in Ollama\n"
"qwen3_vl: Auto-detects local Qwen3-VL model (no API server needed)"
)
}),
"api_endpoint": ("STRING", {
"default": "http://localhost:1234/v1",
"multiline": False,
"tooltip": (
"lm_studio/ollama: API endpoint URL\n"
"qwen3_vl: Leave default, or specify custom model path like 'A:\\path\\to\\model'"
)
}),
"temperature": ("FLOAT", {
"default": 0.7,
"min": 0.1,
"max": 2.0,
"step": 0.1
}),
# Keywords
"positive_keywords": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Enter comma-separated keywords (e.g., lora_trigger, specific_term)"
}),
"negative_keywords": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Enter comma-separated negative terms"
}),
# Output options
"num_variations": ("INT", {
"default": 1,
"min": 1,
"max": 3,
"step": 1
}),
"save_to_file": ("BOOLEAN", {
"default": False
}),
"filename_base": ("STRING", {
"default": "video_prompt",
"multiline": False
})
},
# New inputs are only ever APPENDED here, so saved workflows keep their widget order.
"optional": {
**wildcard_optional_inputs(),
**video_target_inputs(), # target_model, duration_seconds
}
}
@classmethod
def IS_CHANGED(cls, wildcard_seed: int = -1, **kwargs):
"""wildcard_seed = -1 means a fresh roll each run, which must defeat ComfyUI's cache."""
return is_changed_token(wildcard_seed)
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = (
"positive_prompt_1",
"positive_prompt_2",
"positive_prompt_3",
"negative_prompt",
"breakdown",
"status"
)
FUNCTION = "expand_prompt"
CATEGORY = "Eric Prompt Enhancers"
OUTPUT_NODE = True
def expand_prompt(
self,
basic_prompt: str,
preset: str,
expansion_tier: str,
mode: str,
llm_backend: str,
api_endpoint: str,
temperature: float,
positive_keywords: str,
negative_keywords: str,
num_variations: int,
save_to_file: bool,
filename_base: str,
wildcard_seed: int = -1,
wildcard_llm_block: bool = True,
wildcard_instructions: str = "",
# Absent only in API-format workflows saved before these inputs existed: those keep the Wan 2.2 path.
target_model: str = WAN_TARGET,
duration_seconds: float = 0.0
) -> Tuple[str, str, str, str, str, str]:
"""Main processing function"""
try:
# Wildcards and {a|b} alternations are resolved inside expander.expand_prompt by the
# shared engine (core/wildcards). The old early call to _process_alternations is gone:
# it consumed the braces first, which broke {animal} and {label:a|b} in this node.
# Preserve emphasis syntax before LLM processing
basic_prompt = self._preserve_emphasis_syntax(basic_prompt)
pos_kw_list = parse_keywords(positive_keywords)
neg_kw_list = parse_keywords(negative_keywords)
# Initialize LLM backend (model_name auto-detected)
llm = LLMBackend(
backend_type=llm_backend,
endpoint=api_endpoint,
model_name=None, # Auto-detect for all backends
temperature=temperature
)
conn_test = llm.test_connection()
if not conn_test["success"]:
error_msg = f"LLM Connection Failed: {conn_test['message']}"
return (basic_prompt, "", "", "", f"ERROR: {error_msg}", f"❌ {error_msg}")
positive_prompts = []
breakdowns = []
for var_num in range(num_variations):
system_prompt, user_prompt, breakdown_dict = self.expander.expand_prompt(
basic_prompt=basic_prompt,
preset=preset,
tier=expansion_tier,
mode=mode,
positive_keywords=pos_kw_list,
variation_seed=var_num if num_variations > 1 else None,
wildcard_seed=normalise_seed(wildcard_seed, offset=var_num),
wildcard_llm_block=wildcard_llm_block,
wildcard_instructions=wildcard_instructions,
target_model=target_model,
duration_seconds=duration_seconds
)
on_profile_path = breakdown_dict.get("target_model", WAN_TARGET) != WAN_TARGET
response = llm.send_prompt(
system_prompt=system_prompt,
user_prompt=user_prompt,
max_tokens=3000
)
if not response["success"]:
error_msg = response["error"]
return (basic_prompt, "", "", "", f"ERROR: {error_msg}", f"❌ {error_msg}")
parsed = self.expander.parse_llm_response(
response["response"],
target_model=breakdown_dict.get("target_model", WAN_TARGET),
task=breakdown_dict.get("target_task", ""),
word_range=breakdown_dict.get("target_words")
)
enhanced_prompt = parsed["prompt"]
# Restore emphasis syntax after LLM processing
enhanced_prompt = self._restore_emphasis_syntax(enhanced_prompt)
# Validate we got output
if not enhanced_prompt or len(enhanced_prompt) < 20:
return (
basic_prompt,
"",
"",
"",
f"ERROR: LLM returned empty or very short response. Raw: {response['response'][:200]}",
f"❌ LLM response too short - check your model"
)
if on_profile_path:
# Structured formats (MiniMax H3 fields) cannot take text appended at the end
enhanced_prompt, term_warnings = add_required_terms(
enhanced_prompt, pos_kw_list, self.expander.last_resolution, breakdown_dict["target_model"])
breakdown_dict["target_warnings"] = list(parsed.get("warnings") or []) + term_warnings
else:
if pos_kw_list:
keywords_present, missing = validate_positive_keywords(pos_kw_list, enhanced_prompt)
if missing:
enhanced_prompt += f" {', '.join(missing)}"
# Verbatim wildcard picks (__=name__) must survive the rewrite word for word
enhanced_prompt, _restored = enforce_verbatim(enhanced_prompt, self.expander.last_resolution)
positive_prompts.append(enhanced_prompt)
breakdowns.append(breakdown_dict)
while len(positive_prompts) < 3:
positive_prompts.append("")
negative_prompt = self.expander.generate_negative_prompt(
preset=preset,
custom_negatives=neg_kw_list,
mode=mode,
target_model=breakdowns[0].get("target_model", WAN_TARGET)
)
breakdown_text = self._format_all_breakdowns(breakdowns, basic_prompt)
if save_to_file and positive_prompts[0]:
metadata = {
"preset": preset,
"tier": expansion_tier,
"detected_tier": breakdowns[0].get('detected_tier'),
"mode": mode,
"backend": llm_backend,
"model": llm.model_name or "auto-detected",
"temperature": temperature,
"variation_num": num_variations,
"original_prompt": basic_prompt
}
save_result = save_prompts_to_file(
positive_prompt=positive_prompts[0],
negative_prompt=negative_prompt,
breakdown=breakdown_text,
metadata=metadata,
filename_base=filename_base,
output_dir=self.output_dir
)
file_status = f"💾 Saved: {save_result['filepath']}" if save_result["success"] else f"⚠️ Save failed"
else:
file_status = "Not saved"
# Build status - show detected tier if auto was used
detected_tier = breakdowns[0].get('detected_tier')
was_auto = breakdowns[0].get('was_auto_detected', False)
if was_auto and detected_tier:
tier_display = f"Tier: auto→{detected_tier}"
else:
tier_display = f"Tier: {expansion_tier}"
target_display = breakdowns[0].get("target_name") or breakdowns[0].get("target_model", WAN_TARGET)
status = f"✅ Generated {num_variations} variation(s) | {target_display} | {tier_display} | Preset: {preset} | {file_status}"
return (
positive_prompts[0],
positive_prompts[1],
positive_prompts[2],
negative_prompt,
breakdown_text,
status
)
except Exception as e:
import traceback
error_detail = traceback.format_exc()
print(f"ERROR in prompt expansion: {error_detail}")
return (
basic_prompt,
"",
"",
"",
f"ERROR: {str(e)}\n\n{error_detail}",
f"❌ {str(e)}"
)
def _format_all_breakdowns(self, breakdowns: list, original: str) -> str:
"""Format breakdown information"""
if not breakdowns:
return "No breakdown available"
lines = [
"=" * 60,
"PROMPT EXPANSION BREAKDOWN",
"=" * 60,
f"\nOriginal Input:\n{original}\n",
]
# Show if auto-detected
if breakdowns[0].get('was_auto_detected'):
lines.append(f"Tier Setting: auto (detected: {breakdowns[0].get('detected_tier')})")
else:
lines.append(f"Detected Tier: {breakdowns[0].get('detected_tier', 'N/A')}")
lines.extend([
f"Applied Preset: {breakdowns[0].get('applied_preset', 'N/A')}",
f"Mode: {breakdowns[0].get('mode', 'N/A')}",
])
lines.extend(target_report_lines(breakdowns[0]))
for number, breakdown in enumerate(breakdowns[1:], start=2):
lines.extend(f"WARNING (variation {number}): {warning}" for warning in breakdown.get("target_warnings") or [])
if breakdowns[0].get('positive_keywords'):
lines.append(f"Required Keywords: {', '.join(breakdowns[0]['positive_keywords'])}")
if breakdowns[0].get('preset_focus'):
lines.append(f"Focus Areas: {', '.join(breakdowns[0]['preset_focus'])}")
# Wildcard picks, per variation (each variation rolls its own)
for number, breakdown in enumerate(breakdowns, start=1):
replacements = breakdown.get('wildcard_replacements')
if replacements:
picks = "; ".join(f"{name} -> {value}" for name, value in replacements.items())
lines.append(f"Wildcards (variation {number}): {picks}")
for warning in breakdown.get('wildcard_warnings') or []:
lines.append(f"Wildcard warning: {warning}")
lines.append("\n" + "=" * 60)
return "\n".join(lines)
def _process_alternations(self, text: str) -> str:
"""
Process alternation syntax {option1|option2|option3}
Replaces with randomly chosen option
"""
# Pattern to match {option1|option2|option3}
pattern = r'\{([^{}]+)\}'
def replace_alternation(match):
options = match.group(1).split('|')
# Strip whitespace from each option
options = [opt.strip() for opt in options]
return random.choice(options)
# Keep replacing until no more alternations found (handles nested cases)
max_iterations = 10 # Prevent infinite loops
iteration = 0
while '{' in text and '|' in text and iteration < max_iterations:
new_text = re.sub(pattern, replace_alternation, text)
if new_text == text: # No more changes
break
text = new_text
iteration += 1
return text
def _preserve_emphasis_syntax(self, text: str) -> str:
"""
Protect emphasis syntax (keyword:1.5) from being modified
Replaces temporarily with placeholders during LLM processing
"""
# Pattern to match (text:number) emphasis syntax
# This matches things like (dark skin:1.5) or (hair:0.8)
pattern = r'\(([^():]+):(\d+\.?\d*)\)'
# Find all emphasis patterns
emphasis_patterns = re.findall(pattern, text)
# Store original patterns
self._emphasis_store = []
# Replace with placeholders
def replace_emphasis(match):
full_match = match.group(0)
placeholder = f"__EMPHASIS_{len(self._emphasis_store)}__"
self._emphasis_store.append(full_match)
return placeholder
text = re.sub(pattern, replace_emphasis, text)
return text
def _restore_emphasis_syntax(self, text: str) -> str:
"""
Restore emphasis syntax that was protected
"""
if not hasattr(self, '_emphasis_store'):
return text
# Restore placeholders with original emphasis syntax
for i, original in enumerate(self._emphasis_store):
placeholder = f"__EMPHASIS_{i}__"
text = text.replace(placeholder, original)
# Clear the store
self._emphasis_store = []
return text