feat: consolidate style system to 9 styles across both nodes

This commit is contained in:
limbicnation
2026-02-27 05:33:48 +01:00
parent 44ac55e950
commit 272eb01b6c
7 changed files with 487 additions and 79 deletions
-1
View File
@@ -17,4 +17,3 @@ NODE_DISPLAY_NAME_MAPPINGS = {
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+87
View File
@@ -0,0 +1,87 @@
# Code Review: ComfyUI-PromptGenerator
## Executive Summary
The codebase is well-structured with good error handling and graceful degradation patterns. However, there is a **critical inconsistency** in the style system between the two nodes, plus several architectural concerns worth addressing.
---
## 🚨 Critical Issues
### 1. Style System Inconsistency (High Priority)
**Problem:** The two nodes have completely different style options:
| Node | Available Styles |
|------|------------------|
| `PromptGeneratorNode` | cinematic, anime, photorealistic, fantasy, abstract, cyberpunk, sci-fi, video_wan (8 styles) |
| `StyleApplierNode` | cinematic, still_image (2 styles via `StylePreset.get_style_choices()`) |
The `style_presets.py` module only defines 2 styles, while `config/templates.yaml` and `DEFAULT_STYLES` define 8 styles.
**Impact:** Users expect consistency. If they select "anime" style in PromptGenerator, they cannot use StyleApplier with "anime" - it will fail validation.
**Recommendation:** Either:
- Expand `STYLE_DEFINITIONS` in `style_presets.py` to include all 8 styles
- Or consolidate to use templates.yaml as the single source of truth
---
## ⚠️ Architectural Concerns
### 2. Triple Source of Truth for Styles
The codebase has three different places defining styles:
1. `config/templates.yaml` - YAML templates
2. `DEFAULT_STYLES` dict in Python (`prompt_generator_node.py`)
3. `style_presets.py` - StyleKeywords dataclass
**Recommendation:** Consolidate to a single source. The YAML file is user-editable and should be the authoritative source.
### 3. Unused `style_presets.py` Module
The `StylePreset` class is only used by `StyleApplierNode`. The main `PromptGeneratorNode` uses its own `DEFAULT_STYLES` dict and YAML templates.
---
## 🔧 Code Quality Issues
### 4. Missing Style Validation in `generate()`
In `generate()`, there's no validation that the selected style exists - it silently falls back to cinematic.
### 5. Inconsistent Error Messages
- `PromptGeneratorNode` returns emoji-prefixed messages: `"⚠️ Please enter an image description."`
- `StyleApplierNode` returns bracket-prefixed: `"[StyleApplier] Error: Unknown style..."`
**Recommendation:** Standardize error message format.
### 6. Hardcoded Values
- `chunk_timeout = 30` is hardcoded in streaming
- Model list in `_get_available_models()` has hardcoded defaults
---
## ✅ Positive Patterns
1. **Graceful Degradation:** Excellent use of optional imports with fallbacks
2. **Streaming Implementation:** Well-designed background thread approach for timeout enforcement
3. **Cold Start Detection:** Smart 1.3x timeout multiplier for unloaded models
4. **Model Caching:** 60-second cache prevents UI freezes
5. **LoRA Prioritization:** Good sorting logic to surface fine-tuned models first
---
## 📋 Recommendations Priority List
| Priority | Issue | Action |
|----------|-------|--------|
| P0 | Style inconsistency | Sync `style_presets.py` with 8 styles or consolidate |
| P1 | Triple source of truth | Make templates.yaml the single source |
| P2 | Silent style fallback | Add validation/warning when style not found |
| P3 | Error message format | Standardize across nodes |
| P4 | Hardcoded values | Move to config/constants |
BIN
View File
Binary file not shown.
+59 -23
View File
@@ -71,12 +71,14 @@ class PromptGeneratorNode:
ComfyUI node for generating Stable Diffusion prompts using Qwen3-8B via Ollama.
Features:
- 7 style presets (cinematic, anime, photorealistic, fantasy, abstract, cyberpunk, sci-fi)
- 9 style presets loaded from templates.yaml (single source of truth)
- Temperature and Top-P sampling controls
- Optional focus area (emphasis) and mood inputs
- Reasoning toggle to show/hide model's thinking process
"""
CHUNK_TIMEOUT = 30
# Default style templates (fallback when YAML not available)
DEFAULT_STYLES = {
"cinematic": {
@@ -210,6 +212,24 @@ Format the response as a single, detailed sci-fi prompt.""",
"description": "Minimalist template optimized for WanVideo LoRA",
"template": "Generate a video prompt for: {{ description }}{% if emphasis %} with focus on {{ emphasis }}{% endif %}{% if mood %}, mood is {{ mood }}{% endif %}",
},
"still_image": {
"name": "Still Image (Photography)",
"description": "Sharp, realistic photography with technical camera specifications",
"template": """Write a detailed Stable Diffusion prompt for: {{ description }}
Style: Generate a professional photography still image with sharp focus.
{% if emphasis %}Focus particularly on: {{ emphasis }}{% endif %}
{% if mood %}Mood/Atmosphere: {{ mood }}{% endif %}
Include specific details about:
- Camera settings (ISO 100, f/2.8 aperture, sharp optics)
- Natural or studio lighting
- Realistic textures and fine details
- Clean, balanced composition
- Professional photography qualities
Format the response as a single, detailed photography prompt.""",
},
}
# Class-level cache for available models
@@ -264,10 +284,25 @@ Format the response as a single, detailed sci-fi prompt.""",
print(f"[PromptGenerator] Could not fetch models: {e}")
return default_models
@classmethod
def _get_style_list(cls) -> list:
"""Get style list from templates.yaml, falling back to DEFAULT_STYLES keys."""
template_path = Path(__file__).parent.parent / "config" / "templates.yaml"
if YAML_AVAILABLE and template_path.exists():
try:
with open(template_path, "r") as f:
templates = yaml.safe_load(f)
if templates:
return list(templates.keys())
except Exception:
pass
return list(cls.DEFAULT_STYLES.keys())
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
"""Define input parameters for the node."""
available_models = cls._get_available_models()
available_styles = cls._get_style_list()
return {
"required": {
@@ -280,17 +315,12 @@ Format the response as a single, detailed sci-fi prompt.""",
},
),
"style": (
[
"cinematic",
"anime",
"photorealistic",
"fantasy",
"abstract",
"cyberpunk",
"sci-fi",
"video_wan",
],
{"default": "cinematic"},
available_styles,
{
"default": available_styles[0]
if available_styles
else "cinematic"
},
),
"model": (
available_models,
@@ -395,9 +425,13 @@ Format the response as a single, detailed sci-fi prompt.""",
mood: Optional[str] = None,
) -> str:
"""Render a Jinja2 template with the given variables."""
template_data = self.style_templates.get(
style, self.DEFAULT_STYLES.get("cinematic")
)
template_data = self.style_templates.get(style)
if template_data is None:
print(
f"[PromptGenerator] Warning: style '{style}' not found in templates, "
f"falling back to 'cinematic'"
)
template_data = self.DEFAULT_STYLES.get("cinematic")
# Handle YAML format with 'template' key
if isinstance(template_data, dict) and "template" in template_data:
@@ -484,7 +518,7 @@ Format the response as a single, detailed sci-fi prompt.""",
chunks = []
start = time.monotonic()
first_chunk_timeout = min(timeout * 0.6, 90)
chunk_timeout = 30
chunk_timeout = self.CHUNK_TIMEOUT
got_first_chunk = False
try:
@@ -604,7 +638,7 @@ Format the response as a single, detailed sci-fi prompt.""",
Tuple containing the generated prompt string
"""
if not description.strip():
return ("⚠️ Please enter an image description.",)
return ("[PromptGenerator] Please enter an image description.",)
# Render the template
prompt = self._render_template(
@@ -666,7 +700,7 @@ Format the response as a single, detailed sci-fi prompt.""",
print(f"[PromptGenerator] Generated {len(output)} characters")
return (output,)
else:
return ("⚠️ Generation returned empty result.",)
return ("[PromptGenerator] Generation returned empty result.",)
print("[PromptGenerator] Streaming failed, falling back to subprocess")
@@ -680,7 +714,7 @@ Format the response as a single, detailed sci-fi prompt.""",
)
if result.returncode != 0:
return (f"⚠️ Ollama error: {result.stderr}",)
return (f"[PromptGenerator] Ollama error: {result.stderr}",)
output = result.stdout.strip()
@@ -696,11 +730,13 @@ Format the response as a single, detailed sci-fi prompt.""",
)
return (output,)
else:
return ("⚠️ Generation returned empty result.",)
return ("[PromptGenerator] Generation returned empty result.",)
except subprocess.TimeoutExpired:
return (f"⚠️ Generation timed out after {timeout}s",)
return (f"[PromptGenerator] Generation timed out after {timeout}s",)
except FileNotFoundError:
return ("⚠️ Ollama not found. Install from: https://ollama.ai",)
return (
"[PromptGenerator] Ollama not found. Install from: https://ollama.ai",
)
except Exception as e:
return (f"⚠️ Error: {str(e)}",)
return (f"[PromptGenerator] Error: {str(e)}",)
+29 -19
View File
@@ -9,52 +9,61 @@ from typing import Tuple
class StyleApplierNode:
"""
ComfyUI node for applying Cinematic or Still Image style keywords to prompts.
Inputs:
- prompt: Base prompt text
- style: "cinematic" or "still_image"
- position: Where to add keywords ("prefix", "suffix", or "wrap")
- emphasis: Optional emphasis level ("low", "medium", "high")
- include_technical: Include camera/technical specs
Outputs:
- styled_prompt: The prompt with style keywords added
- style_keywords: Just the style keywords (for reference)
"""
@classmethod
def INPUT_TYPES(cls):
"""Define input parameters for the node."""
from style_presets import StylePreset
return {
"required": {
"prompt": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Enter your base prompt..."
}),
"prompt": (
"STRING",
{
"multiline": True,
"default": "",
"placeholder": "Enter your base prompt...",
},
),
"style": (StylePreset.get_style_choices(), {"default": "cinematic"}),
},
"optional": {
"position": (["suffix", "prefix", "wrap"], {"default": "suffix"}),
"emphasis": (["medium", "low", "high"], {"default": "medium"}),
"include_technical": ("BOOLEAN", {"default": True}),
}
},
}
RETURN_TYPES = ("STRING", "STRING",)
RETURN_NAMES = ("styled_prompt", "style_keywords",)
RETURN_TYPES = (
"STRING",
"STRING",
)
RETURN_NAMES = (
"styled_prompt",
"style_keywords",
)
FUNCTION = "apply_style"
CATEGORY = "text/generation"
def apply_style(
self,
prompt: str,
style: str,
position: str = "suffix",
emphasis: str = "medium",
include_technical: bool = True
include_technical: bool = True,
) -> Tuple[str, str]:
"""Apply style keywords to a prompt."""
from style_presets import StylePreset
@@ -66,14 +75,15 @@ class StyleApplierNode:
# Validate style
available_styles = StylePreset.get_style_choices()
if style not in available_styles:
return (f"[StyleApplier] Error: Unknown style '{style}'. Available: {available_styles}", "")
return (
f"[StyleApplier] Error: Unknown style '{style}'. Available: {available_styles}",
"",
)
# Get style keywords
try:
style_keywords = StylePreset().get_style_prompt(
style=style,
emphasis=emphasis,
include_technical=include_technical
style=style, emphasis=emphasis, include_technical=include_technical
)
except ValueError as e:
return (f"[StyleApplier] Error getting style: {e}", "")
@@ -0,0 +1,36 @@
# Social Media Posts - v1.1.6 Bug Fix Release
## LinkedIn Post
---
**Fixed the 120s timeout issue in ComfyUI Prompt Generator (v1.1.6)**
If you've been hitting "Generation Timed Out 120s" errors with the ComfyUI Prompt Generator node - this is now resolved.
The root cause: the blocking `ollama.generate()` call had no way to handle slow model responses or cold starts gracefully.
What changed in v1.1.6:
- Replaced blocking API call with streaming + per-chunk timeouts
- Added cold start detection with automatic timeout multiplier
- Integrated ComfyUI ProgressBar so you can see generation progress in real time
Update via ComfyUI Manager or pull the latest from GitHub.
https://github.com/Limbicnation/ComfyUI-PromptGenerator
#ComfyUI #StableDiffusion #Ollama #AIArt #OpenSource #BugFix
---
## YouTube Reply to @luci0c4s3
---
> @luci0c4s3: "Getting Generation Timed 120s as an error each time I run the node."
**Reply:**
Hey! This was a known issue and it's been fixed in v1.1.6. The blocking Ollama call was replaced with streaming + per-chunk timeouts, which eliminates the 120s timeout error. Cold starts also get extra time automatically now. Update via ComfyUI Manager or grab the latest from GitHub: https://github.com/Limbicnation/ComfyUI-PromptGenerator
---
+276 -36
View File
@@ -1,13 +1,13 @@
"""
Style Presets Module for ComfyUI
Modular style system for prompt generation with Cinematic and Still Image modes.
Modular style system for prompt generation with keyword sets for each style.
Usage in ComfyUI nodes:
from style_presets import StylePreset
# Get style choices for INPUT_TYPES dropdown
choices = StylePreset.get_style_choices() # ("cinematic", "still_image")
choices = StylePreset.get_style_choices()
# Get style keywords as a prompt string
preset = StylePreset()
style_prompt = preset.get_style_prompt("cinematic")
@@ -20,38 +20,54 @@ from enum import Enum
class StyleMode(Enum):
"""Enumeration of available style modes."""
CINEMATIC = "cinematic"
STILL_IMAGE = "still_image"
ANIME = "anime"
PHOTOREALISTIC = "photorealistic"
FANTASY = "fantasy"
ABSTRACT = "abstract"
CYBERPUNK = "cyberpunk"
SCI_FI = "sci-fi"
VIDEO_WAN = "video_wan"
@dataclass
class StyleKeywords:
"""Container for style-specific keywords and descriptors."""
primary: List[str] = field(default_factory=list)
lighting: List[str] = field(default_factory=list)
technical: List[str] = field(default_factory=list)
composition: List[str] = field(default_factory=list)
texture: List[str] = field(default_factory=list)
def to_prompt_string(self, separator: str = ", ") -> str:
"""Convert all keywords to a single prompt string."""
all_keywords = (
self.primary + self.lighting + self.technical +
self.composition + self.texture
self.primary
+ self.lighting
+ self.technical
+ self.composition
+ self.texture
)
return separator.join(all_keywords)
def to_list(self) -> List[str]:
"""Return all keywords as a flat list (ComfyUI-compatible)."""
return (
self.primary + self.lighting + self.technical +
self.composition + self.texture
self.primary
+ self.lighting
+ self.technical
+ self.composition
+ self.texture
)
@dataclass
class StyleDefinition:
"""Complete style definition with metadata and keywords."""
name: str
description: str
keywords: StyleKeywords
@@ -64,23 +80,251 @@ STYLE_DEFINITIONS: Dict[StyleMode, StyleDefinition] = {
description="Film-like visuals with dramatic lighting and anamorphic qualities",
keywords=StyleKeywords(
primary=["cinematic shot", "film grain", "movie still", "dramatic scene"],
lighting=["dramatic lighting", "volumetric light", "rim lighting", "chiaroscuro", "golden hour"],
technical=["anamorphic lens", "shallow depth of field", "bokeh", "35mm film", "wide aspect ratio"],
composition=["rule of thirds", "leading lines", "dynamic composition", "cinematic framing"],
texture=["rich color grading", "film texture", "atmospheric haze"]
)
lighting=[
"dramatic lighting",
"volumetric light",
"rim lighting",
"chiaroscuro",
"golden hour",
],
technical=[
"anamorphic lens",
"shallow depth of field",
"bokeh",
"35mm film",
"wide aspect ratio",
],
composition=[
"rule of thirds",
"leading lines",
"dynamic composition",
"cinematic framing",
],
texture=["rich color grading", "film texture", "atmospheric haze"],
),
),
StyleMode.STILL_IMAGE: StyleDefinition(
name="Still Image (Photography)",
description="Sharp, realistic photography with technical camera specifications",
keywords=StyleKeywords(
primary=["professional photography", "high resolution", "sharp focus", "studio quality"],
lighting=["natural lighting", "soft diffused light", "studio lighting", "balanced exposure"],
technical=["f/2.8 aperture", "ISO 100", "sharp optics", "full frame sensor", "RAW quality"],
composition=["centered composition", "clean framing", "balanced layout", "professional angle"],
texture=["realistic textures", "fine details", "crisp definition", "accurate colors"]
)
)
primary=[
"professional photography",
"high resolution",
"sharp focus",
"studio quality",
],
lighting=[
"natural lighting",
"soft diffused light",
"studio lighting",
"balanced exposure",
],
technical=[
"f/2.8 aperture",
"ISO 100",
"sharp optics",
"full frame sensor",
"RAW quality",
],
composition=[
"centered composition",
"clean framing",
"balanced layout",
"professional angle",
],
texture=[
"realistic textures",
"fine details",
"crisp definition",
"accurate colors",
],
),
),
StyleMode.ANIME: StyleDefinition(
name="Anime",
description="Vibrant anime-style illustration with dynamic colors",
keywords=StyleKeywords(
primary=[
"anime style",
"manga illustration",
"cel shading",
"vibrant colors",
],
lighting=[
"soft glow",
"dramatic backlighting",
"ambient light",
"bloom effect",
],
technical=[
"clean linework",
"flat color areas",
"high contrast",
"detailed eyes",
],
composition=[
"dynamic pose",
"expressive composition",
"layered background",
],
texture=["smooth gradients", "soft skin tones", "vivid saturation"],
),
),
StyleMode.PHOTOREALISTIC: StyleDefinition(
name="Photorealistic",
description="High-detail realistic images with natural lighting",
keywords=StyleKeywords(
primary=[
"photorealistic",
"ultra realistic",
"lifelike detail",
"high fidelity",
],
lighting=[
"natural lighting",
"golden hour",
"soft shadows",
"ambient occlusion",
],
technical=[
"DSLR quality",
"85mm lens",
"shallow depth of field",
"8K resolution",
],
composition=["rule of thirds", "natural framing", "environmental portrait"],
texture=[
"realistic skin texture",
"fine material detail",
"natural imperfections",
],
),
),
StyleMode.FANTASY: StyleDefinition(
name="Fantasy",
description="Magical elements and themes with ethereal atmosphere",
keywords=StyleKeywords(
primary=["fantasy art", "magical scene", "mythical", "enchanted"],
lighting=[
"ethereal glow",
"mystical light rays",
"bioluminescence",
"aurora",
],
technical=["digital painting", "matte painting", "concept art quality"],
composition=[
"epic scale",
"layered depth",
"grand vista",
"ornate framing",
],
texture=[
"iridescent surfaces",
"crystalline detail",
"ancient stonework",
"enchanted flora",
],
),
),
StyleMode.ABSTRACT: StyleDefinition(
name="Abstract",
description="Artistic abstract compositions with creative expression",
keywords=StyleKeywords(
primary=["abstract art", "non-representational", "artistic composition"],
lighting=["color field lighting", "gradient transitions", "luminous forms"],
technical=["mixed media", "generative art", "high dynamic range"],
composition=[
"visual rhythm",
"asymmetric balance",
"flowing forms",
"geometric patterns",
],
texture=[
"paint strokes",
"textured layers",
"organic forms",
"splatter effects",
],
),
),
StyleMode.CYBERPUNK: StyleDefinition(
name="Cyberpunk",
description="Neon lights, high technology, and urban dystopia",
keywords=StyleKeywords(
primary=[
"cyberpunk",
"neon noir",
"dystopian future",
"high tech low life",
],
lighting=[
"neon glow",
"holographic reflections",
"LED strips",
"rain-soaked neon",
],
technical=[
"ray tracing",
"volumetric fog",
"chromatic aberration",
"lens flare",
],
composition=[
"urban canyon",
"vertical composition",
"dense layering",
"vanishing point",
],
texture=[
"wet asphalt reflections",
"rust and chrome",
"holographic surfaces",
"circuit patterns",
],
),
),
StyleMode.SCI_FI: StyleDefinition(
name="Sci-Fi",
description="Futuristic technology and space exploration scenes",
keywords=StyleKeywords(
primary=[
"science fiction",
"futuristic",
"space opera",
"advanced technology",
],
lighting=["starlight", "plasma glow", "engine flare", "atmospheric entry"],
technical=[
"hard surface modeling",
"concept art",
"matte painting",
"photobashing",
],
composition=[
"epic scale",
"orbital view",
"dramatic perspective",
"vast emptiness",
],
texture=[
"polished metal",
"energy fields",
"alien materials",
"hull plating",
],
),
),
StyleMode.VIDEO_WAN: StyleDefinition(
name="Video (WanVideo)",
description="Minimalist keywords optimized for WanVideo LoRA",
keywords=StyleKeywords(
primary=["video shot", "motion capture", "fluid movement"],
lighting=["cinematic lighting", "natural light"],
technical=["smooth motion", "24fps", "high resolution video"],
composition=["dynamic camera", "tracking shot"],
texture=["temporal consistency", "clean frames"],
),
),
}
@@ -101,40 +345,37 @@ def get_available_styles() -> List[str]:
class StylePreset:
"""ComfyUI-compatible style preset class."""
def __init__(self):
self._styles = STYLE_DEFINITIONS
@staticmethod
def get_style_choices() -> Tuple[str, ...]:
"""Get available style choices as tuple (ComfyUI dropdown format)."""
return tuple(mode.value for mode in StyleMode)
def get_style_keywords(self, style: str) -> List[str]:
"""Get style keywords as a list."""
return get_style_keywords(style).to_list()
def get_style_prompt(
self,
style: str,
emphasis: Optional[str] = None,
include_technical: bool = True
self, style: str, emphasis: Optional[str] = None, include_technical: bool = True
) -> str:
"""Get a formatted style prompt string."""
keywords = get_style_keywords(style)
parts = []
parts.extend(keywords.primary)
parts.extend(keywords.lighting)
parts.extend(keywords.composition)
parts.extend(keywords.texture)
if include_technical:
parts.extend(keywords.technical)
if emphasis:
parts.insert(0, f"emphasis on {emphasis}")
return ", ".join(parts)
@@ -145,6 +386,5 @@ __all__ = [
"StylePreset",
"get_style_keywords",
"get_available_styles",
"STYLE_DEFINITIONS"
"STYLE_DEFINITIONS",
]