Files
slahiri-ComfyUI-AI-Photogra…/sid_zimage_prompt_generator.py
slahiriandClaude Opus 4.5 0012ad531b feat: v4.2.1 - Modular prompt generator with enhanced LLM modes
Major Features:
- New modular prompt generator architecture (core/prompt_generator/)
- 15+ taggers/analyzers: WD14, PixAI, JoyTag, NudeNet, Places365, etc.
- Decision engine for intelligent prompt construction
- Template-based prompt building with dynamic sections

Standard Mode Enhancements:
- Tag injection for unmapped high-confidence tags
- Scene-specific prompts (20 scene groups from Places365)
- Animal-specific prompts (20+ animal types)
- Clothing-specific prompts (30+ clothing types)
- Tag-driven detail enhancements (40+ tag mappings)
- Semantic inclusion analysis using sentence-transformers

Detailed Mode (API with reasoning):
- 3-pass LLM generation with full enhancements
- Falls back to Standard mode for local models
- Comprehensive metadata with pass-by-pass details

Removed:
- Extreme mode (consolidated into Detailed mode)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 00:23:50 +05:30

1587 lines
66 KiB
Python

"""
SID_ZImagePromptGenerator Node (Unified)
Single Z-Image prompt generator with automatic mode selection.
Auto-switches between Single-Shot and Agentic modes based on LLM capabilities.
Features:
- 5 simple inputs: image, llm_model, analysis_mode, preset_style, user_guidance, seed
- Auto-detects reasoning capability from llm_model
- Component analysis based on analysis_mode
- Preset styles for common use cases
- Image resize options for optimal model performance
"""
import base64
import io
import json
import re
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from PIL import Image
from comfy_api.latest import io as comfy_io
import comfy.utils
from .llm_providers.llm_model_type import LLMModelConfig
from . import config_loader
# =============================================================================
# Model Resolution Configurations
# =============================================================================
# Optimal image sizes for different providers/models
# Format: (min_pixels, optimal_pixels, max_pixels)
# Pixels = width * height
MODEL_RESOLUTION_LIMITS = {
# API providers - generally handle larger images well
"anthropic": {
"min_pixels": 256 * 256, # ~65K pixels
"optimal_pixels": 1024 * 1024, # ~1M pixels (good balance)
"max_pixels": 2048 * 2048, # ~4M pixels
},
"openai": {
"min_pixels": 256 * 256,
"optimal_pixels": 1024 * 1024,
"max_pixels": 2048 * 2048,
},
"gemini": {
"min_pixels": 256 * 256,
"optimal_pixels": 1024 * 1024,
"max_pixels": 3072 * 3072,
},
"grok": {
"min_pixels": 256 * 256,
"optimal_pixels": 1024 * 1024,
"max_pixels": 2048 * 2048,
},
# Free/budget providers - smaller is faster
"groq": {
"min_pixels": 384 * 384,
"optimal_pixels": 768 * 768,
"max_pixels": 1024 * 1024,
},
"openrouter": {
"min_pixels": 256 * 256,
"optimal_pixels": 1024 * 1024,
"max_pixels": 2048 * 2048,
},
"together": {
"min_pixels": 384 * 384,
"optimal_pixels": 768 * 768,
"max_pixels": 1536 * 1536,
},
"fireworks": {
"min_pixels": 384 * 384,
"optimal_pixels": 768 * 768,
"max_pixels": 1536 * 1536,
},
# Local providers - optimized for VRAM efficiency
"local": {
"min_pixels": 256 * 256, # Fast processing
"optimal_pixels": 672 * 672, # Good balance for 4-8GB VRAM
"max_pixels": 1280 * 1280, # Maximum detail
},
"ollama": {
"min_pixels": 256 * 256,
"optimal_pixels": 672 * 672,
"max_pixels": 1280 * 1280,
},
"lmstudio": {
"min_pixels": 256 * 256,
"optimal_pixels": 672 * 672,
"max_pixels": 1280 * 1280,
},
# Default for unknown providers
"default": {
"min_pixels": 384 * 384,
"optimal_pixels": 768 * 768,
"max_pixels": 1536 * 1536,
},
}
# Image resize modes
IMAGE_RESIZE_MODES = ["auto", "max", "min", "original"]
def calculate_resize_dimensions(
width: int,
height: int,
target_pixels: int,
maintain_aspect: bool = True
) -> Tuple[int, int]:
"""
Calculate new dimensions to achieve target pixel count while maintaining aspect ratio.
Args:
width: Original width
height: Original height
target_pixels: Target total pixels (width * height)
maintain_aspect: Whether to maintain aspect ratio
Returns:
Tuple of (new_width, new_height)
"""
current_pixels = width * height
if current_pixels <= target_pixels:
return width, height
# Calculate scale factor
scale = (target_pixels / current_pixels) ** 0.5
new_width = int(width * scale)
new_height = int(height * scale)
# Ensure dimensions are at least 64 pixels
new_width = max(64, new_width)
new_height = max(64, new_height)
# Round to nearest multiple of 8 for better model compatibility
new_width = (new_width // 8) * 8
new_height = (new_height // 8) * 8
return new_width, new_height
# Create custom LLM_MODEL type for ComfyUI
LLM_MODEL_Type = comfy_io.Custom("LLM_MODEL")
# =============================================================================
# Analysis Mode Configurations
# =============================================================================
ANALYSIS_MODES = {
"Quick": {
"components": ["framing", "ethnicity", "clothing"],
"description": "Fast single-call caption (~3s)",
"include_lighting": False,
"include_pose": False,
"include_intimate": False,
"include_tattoos": False,
},
"Standard": {
"components": ["framing", "ethnicity", "hair", "face", "eyes", "body_pose", "body_figure", "clothing", "lighting"],
"description": "Balanced analysis with body proportions (~12s)",
"include_lighting": True,
"include_pose": True,
"include_intimate": False,
"include_tattoos": False,
},
"Detailed": {
"components": ["framing", "ethnicity", "hair", "face", "eyes", "nose_lips", "body_pose", "body_figure", "clothing", "intimate_apparel", "tattoos", "accessories", "lighting"],
"description": "Full analysis with body figure/intimate/tattoos (~25s)",
"include_lighting": True,
"include_pose": True,
"include_intimate": True,
"include_tattoos": True,
},
"Extreme": {
"components": ["framing", "ethnicity", "hair", "face", "eyes", "nose_lips", "body_pose", "body_figure", "clothing", "intimate_apparel", "tattoos", "accessories", "lighting"],
"description": "Maximum detail with body figure, raw output (~30s)",
"include_lighting": True,
"include_pose": True,
"include_intimate": True,
"include_tattoos": True,
"raw_mode": True,
},
}
# =============================================================================
# Preset Style Configurations
# =============================================================================
PRESET_STYLES = {
"Auto-Detect": {
"description": "Smart subject detection (Woman/Man/Vehicle/Landscape)",
"system_addon": "",
"user_addon": "",
},
"Portrait": {
"description": "Face, expression, upper body focus",
"system_addon": "Focus heavily on facial features, expression, skin quality, and makeup details.",
"user_addon": "Emphasize the subject's face, expression, and upper body appearance.",
},
"Fashion & Outfit": {
"description": "Clothing, accessories, styling focus",
"system_addon": "Focus heavily on clothing details, materials, colors, fit, and accessories.",
"user_addon": "Emphasize all clothing items, materials, textures, and fashion styling.",
},
"Artistic Style": {
"description": "Composition, lighting, mood focus",
"system_addon": "Focus on artistic and photographic qualities - composition, lighting, color palette, mood.",
"user_addon": "Emphasize the artistic qualities: composition, lighting style, color tones, and overall mood.",
},
"NSFW/Detailed": {
"description": "Comprehensive body and clothing analysis",
"system_addon": "Include comprehensive body and intimate apparel details. Be specific about all visible features.",
"user_addon": "Provide detailed analysis of body, intimate apparel, and all visible features.",
},
}
# =============================================================================
# Component Definitions for Analysis
# =============================================================================
COMPONENTS = {
"framing": {
"name": "Framing & Composition",
"prompt": """Analyze ONLY the framing and composition:
1. COLOR MODE: Color or black & white?
2. SHOT TYPE: Close-up (CU) / Medium (MS) / Wide (WS) / Extreme wide (EWS) / Detail shot
3. CAMERA ANGLE: Eye level / High angle / Low angle / Bird's eye / Worm's eye
4. DEPTH OF FIELD: Shallow (blurred background) / Medium / Deep (sharp throughout)
5. COMPOSITION: Rule of thirds / Centered / Leading lines / Symmetrical / Golden ratio
Output JSON:
{"shot_type": "<type>", "camera_angle": "<angle>", "depth_of_field": "<dof>", "composition": "<style>", "color_mode": "<color/bw>", "prompt_description": "<complete framing and composition description>"}"""
},
"ethnicity": {
"name": "Ethnicity & Demographics",
"prompt": """Analyze the subject's demographics:
1. ETHNICITY: Be specific (East Asian, South Asian, European, etc.)
2. SKIN TONE: Base tone + undertone (warm/cool/olive)
3. AGE RANGE: young adult, adult, middle-aged, etc.
4. GENDER: female/male
Output JSON:
{"ethnicity": "<specific>", "skin_tone_base": "<tone>", "skin_tone_undertone": "<undertone>", "age_range": "<range>", "gender": "<gender>", "prompt_description": "<demographics description>"}"""
},
"hair": {
"name": "Hair Analysis",
"prompt": """Analyze the hair (ARRANGEMENT FIRST):
1. ARRANGEMENT: Updo/Ponytail/Braided/Half-up/Loose/Short
2. COLOR: Base color + highlights
3. TEXTURE: Straight/wavy/curly
4. LENGTH: Short/shoulder/mid-back/long
Output JSON:
{"arrangement": "<style>", "base_color": "<color>", "texture": "<texture>", "length": "<length>", "prompt_description": "<hair description starting with arrangement>"}"""
},
"face": {
"name": "Face Shape & Angle",
"prompt": """Analyze face shape and positioning:
1. FACE SHAPE: Oval/round/heart/square/diamond
2. FACE ANGLE: Frontal/3/4 left/3/4 right/profile
3. HEAD TILT: Level/tilted
Output JSON:
{"shape": "<shape>", "angle": "<angle>", "tilt": "<tilt>", "prompt_description": "<face description>"}"""
},
"eyes": {
"name": "Eyes Analysis",
"prompt": """Analyze the eyes:
1. COLOR: Specific color with variations
2. SHAPE: Almond/round/hooded/monolid
3. GAZE: Direction
4. EXPRESSION: Soft/intense/playful
5. MAKEUP: If any
Output JSON:
{"color": "<color>", "shape": "<shape>", "gaze": "<direction>", "expression": "<expression>", "makeup": "<if any>", "prompt_description": "<eyes description>"}"""
},
"nose_lips": {
"name": "Nose & Lips",
"prompt": """Analyze nose and lips:
NOSE: Shape, bridge height
LIPS: Shape, fullness, color/makeup, state (closed/parted/smiling)
Output JSON:
{"nose_shape": "<shape>", "lip_shape": "<shape>", "lip_color": "<color>", "lip_state": "<state>", "prompt_description": "<nose and lips description>"}"""
},
"body_pose": {
"name": "Body & Pose",
"prompt": """Analyze visible body and pose:
1. VISIBLE PARTS: What's visible
2. POSTURE: Upright/relaxed/leaning
3. ARM/HAND POSITIONS: If visible
4. BODY ANGLE: Facing camera or angled
Output JSON:
{"visible_parts": ["<list>"], "posture": "<posture>", "body_angle": "<angle>", "arms": "<position>", "prompt_description": "<pose description>"}"""
},
"body_figure": {
"name": "Body Figure & Proportions",
"prompt": """Analyze BODY FIGURE, PROPORTIONS, and EXPOSURE LEVEL visible in this image.
CRITICAL: Only describe what is ACTUALLY VISIBLE.
1. NUDITY/EXPOSURE LEVEL:
- Fully clothed / Partially clothed / Revealing / Swimwear-Lingerie / Topless / Nude / Implied nude
2. BREAST/CHEST EXPOSURE:
- Covered / Cleavage only / Sideboob (left/right/both) / Underboob / Partial / Full
- Nipple visibility: covered / through fabric / partial / full
3. BODY TYPE: Slim / Athletic / Average / Curvy-Voluptuous / Petite / Plus-size
4. BUST DETAILS (if visible):
- Cup size estimate: AA/A (minimal) / B (small) / C (medium) / D (large) / DD-E (very large) / DDD-F (extra large) / G+ (extremely large)
- Shape: natural / rounded / teardrop / projected / perky / full
- Cleavage: none / minimal / moderate / prominent / deep
- Cleavage shadow: none / subtle / defined / dramatic
- Position: natural / lifted / pushed together / separated
5. WAIST (if visible):
- Definition: undefined / slight / defined / very defined (hourglass)
- Midriff visible: yes/no
6. HIPS & BUTTOCKS (if visible):
- Hip width: narrow / medium / wide / very wide
- Hip curve: subtle / moderate / pronounced / dramatic
- Buttocks shape: flat / moderate / rounded / prominent / heart-shaped
- Buttocks exposure: covered / partial / full
7. THIGHS (if visible):
- Fullness: slim / toned / moderate / full / thick
- Thigh gap: present / touching / close
8. BODY SHADOWS & CONTOURS:
- Under bust shadow: none / subtle / defined / dramatic
- Overall sculpting: soft / moderate / dramatic
Output JSON:
{"nudity_level": "<level>", "breast_exposure": {"level": "<level>", "sideboob": "<none/left/right/both>", "underboob": true/false, "nipple": "<visibility>"}, "body_type": "<type>", "bust": {"visible": true/false, "cup_size": "<AA-G+>", "shape": "<shape>", "cleavage": "<level>", "cleavage_shadow": "<level>", "position": "<position>"}, "waist": {"visible": true/false, "definition": "<level>", "midriff_exposed": true/false}, "hips": {"visible": true/false, "width": "<width>", "curve": "<curve>"}, "buttocks": {"visible": true/false, "shape": "<shape>", "exposure": "<level>"}, "thighs": {"visible": true/false, "fullness": "<level>", "gap": "<status>"}, "body_shadows": {"under_bust": "<level>", "sculpting": "<level>"}, "prompt_description": "<complete body figure description>"}"""
},
"clothing": {
"name": "Clothing Analysis",
"prompt": """Analyze ONLY VISIBLE clothing:
For each garment:
1. TYPE: Dress/top/pants/etc.
2. NECKLINE: If visible
3. COLOR: Specific
4. MATERIAL: Silk/satin/cotton/etc.
5. FIT: Fitted/loose
ONLY describe what you can SEE - do not invent details.
Output JSON:
{"garments": [{"type": "<type>", "color": "<color>", "material": "<material>"}], "overall_style": "<style>", "prompt_description": "<clothing description>"}"""
},
"intimate_apparel": {
"name": "Intimate Apparel",
"prompt": """Analyze visible intimate apparel/lingerie:
BRA: Style, coverage, straps, material, color
BOTTOMS: Style, coverage, material
GARTER/STOCKINGS: If visible
Output JSON:
{"bra": {"style": "<style>", "material": "<material>", "color": "<color>"}, "bottoms": {"style": "<style>", "color": "<color>"}, "stockings": {"present": true/false, "type": "<type>"}, "prompt_description": "<intimate apparel description>"}"""
},
"tattoos": {
"name": "Tattoos",
"prompt": """Analyze visible tattoos:
For each: Location, size, style, subject, colors
Output JSON:
{"has_tattoos": true/false, "tattoos": [{"location": "<location>", "style": "<style>", "subject": "<subject>"}], "prompt_description": "<tattoos description or 'no visible tattoos'>"}"""
},
"accessories": {
"name": "Accessories",
"prompt": """Analyze accessories/jewelry:
For each: Type, style, material, color, size
Output JSON:
{"accessories": [{"type": "<type>", "material": "<material>", "color": "<color>"}], "prompt_description": "<accessories description>"}"""
},
"lighting": {
"name": "Lighting & Background",
"prompt": """Analyze lighting and background:
LIGHTING: Type, direction, quality, color temperature
SHADOWS: Density, direction
BACKGROUND: Type, color, blur level
Output JSON:
{"light_type": "<type>", "light_direction": "<direction>", "light_quality": "<quality>", "background_type": "<type>", "background_color": "<color>", "background_blur": "<blur>", "prompt_description": "<lighting and background description>"}"""
},
"subject_detection": {
"name": "Subject Detection",
"prompt": """Identify the PRIMARY SUBJECT TYPE:
WOMAN, MAN, COUPLE, GROUP, VEHICLE, LANDSCAPE, ANIMAL, PRODUCT, OTHER
Output JSON:
{"subject_type": "<type>", "has_human": true/false, "human_gender": "<female/male/none>", "main_focus": "<description>"}"""
},
# Landscape-specific components
"landscape_terrain": {
"name": "Landscape Terrain",
"prompt": """Analyze the TERRAIN and NATURAL FEATURES:
1. TERRAIN TYPE: Mountains/Hills/Plains/Desert/Forest/Beach/Ocean/Lake/River/Urban/Rural
2. GROUND COVER: Grass/Sand/Rock/Snow/Water/Pavement/Dirt
3. VEGETATION: Dense forest/Sparse trees/Grassland/Tropical/None
4. WATER FEATURES: Ocean/Lake/River/Waterfall/Stream/Pond/None
5. ELEVATION: Flat/Rolling/Mountainous/Cliffs/Valleys
Output JSON:
{"terrain_type": "<type>", "ground_cover": "<cover>", "vegetation": "<type>", "water_features": "<type>", "elevation": "<type>", "prompt_description": "<terrain description>"}"""
},
"landscape_weather": {
"name": "Weather & Atmosphere",
"prompt": """Analyze the WEATHER and ATMOSPHERIC CONDITIONS:
1. SKY CONDITION: Clear/Partly cloudy/Overcast/Stormy/Sunset/Sunrise/Night
2. CLOUD TYPES: None/Cumulus/Stratus/Cirrus/Dramatic storm clouds
3. VISIBILITY: Clear/Hazy/Foggy/Misty/Smoky
4. WEATHER: Sunny/Rainy/Snowy/Windy/Calm
5. TIME OF DAY: Dawn/Morning/Midday/Afternoon/Golden hour/Dusk/Night
Output JSON:
{"sky_condition": "<condition>", "clouds": "<type>", "visibility": "<visibility>", "weather": "<weather>", "time_of_day": "<time>", "prompt_description": "<weather and atmosphere description>"}"""
},
"landscape_scenery": {
"name": "Scenery & Elements",
"prompt": """Analyze the SCENIC ELEMENTS and COMPOSITION:
1. FOCAL POINT: What draws the eye (mountain peak, building, tree, horizon, etc.)
2. FOREGROUND: What's in the nearest plane
3. MIDGROUND: Middle distance elements
4. BACKGROUND: Distant elements (horizon, sky, mountains)
5. MAN-MADE ELEMENTS: Buildings/Roads/Bridges/Fences/None
6. NATURAL LANDMARKS: Peaks/Rock formations/Trees/Rivers/None
Output JSON:
{"focal_point": "<element>", "foreground": "<description>", "midground": "<description>", "background": "<description>", "man_made": ["<elements>"], "natural_landmarks": ["<landmarks>"], "prompt_description": "<scenery description>"}"""
},
# Vehicle-specific components
"vehicle_details": {
"name": "Vehicle Details",
"prompt": """Analyze the VEHICLE in detail:
1. VEHICLE TYPE: Car/Motorcycle/Truck/Bus/Bicycle/Boat/Aircraft/Train
2. MAKE/MODEL: If identifiable (e.g., "Ferrari", "Harley Davidson")
3. BODY STYLE: Sedan/Coupe/SUV/Convertible/Hatchback/Sports/Classic/etc.
4. COLOR: Primary color and any accents
5. CONDITION: New/Used/Vintage/Restored/Modified/Damaged
6. ANGLE: Front/Rear/Side/3-quarter/Top-down
7. NOTABLE FEATURES: Spoilers/Rims/Decals/Custom parts
Output JSON:
{"vehicle_type": "<type>", "make_model": "<make_model>", "body_style": "<style>", "color": "<color>", "condition": "<condition>", "angle": "<angle>", "features": ["<features>"], "prompt_description": "<vehicle description>"}"""
},
# Animal-specific component
"animal_details": {
"name": "Animal Details",
"prompt": """Analyze the ANIMAL in detail:
1. SPECIES: Specific animal type (dog, cat, horse, bird, etc.)
2. BREED: If identifiable
3. COLORING: Fur/feather color and pattern
4. POSE: Standing/Sitting/Lying/Running/Flying/Swimming
5. EXPRESSION: Alert/Relaxed/Playful/Aggressive/Sleeping
6. SIZE: Small/Medium/Large (relative to frame)
Output JSON:
{"species": "<species>", "breed": "<breed>", "coloring": "<description>", "pose": "<pose>", "expression": "<expression>", "size": "<size>", "prompt_description": "<animal description>"}"""
},
# Product-specific component
"product_details": {
"name": "Product Details",
"prompt": """Analyze the PRODUCT in detail:
1. PRODUCT TYPE: Electronics/Food/Cosmetics/Clothing/Furniture/Tool/etc.
2. BRAND: If visible
3. COLOR: Primary colors
4. MATERIAL: Metal/Plastic/Glass/Wood/Fabric/etc.
5. CONDITION: New/Used/Packaged/Displayed
6. STYLING: Product photography style (studio/lifestyle/flat-lay)
Output JSON:
{"product_type": "<type>", "brand": "<brand>", "color": "<color>", "material": "<material>", "condition": "<condition>", "styling": "<style>", "prompt_description": "<product description>"}"""
},
}
# =============================================================================
# Subject Type to Components Mapping
# =============================================================================
# Components that are human-specific (only apply to WOMAN, MAN, COUPLE, GROUP)
HUMAN_COMPONENTS = {
"ethnicity", "hair", "face", "eyes", "nose_lips", "body_pose", "body_figure",
"clothing", "intimate_apparel", "tattoos", "accessories"
}
# Components for each subject type
SUBJECT_COMPONENTS = {
"WOMAN": ["framing", "ethnicity", "hair", "face", "eyes", "nose_lips", "body_pose", "body_figure", "clothing", "intimate_apparel", "tattoos", "accessories", "lighting"],
"MAN": ["framing", "ethnicity", "hair", "face", "eyes", "nose_lips", "body_pose", "body_figure", "clothing", "tattoos", "accessories", "lighting"],
"COUPLE": ["framing", "ethnicity", "hair", "face", "eyes", "body_pose", "clothing", "accessories", "lighting"],
"GROUP": ["framing", "ethnicity", "clothing", "lighting"],
"LANDSCAPE": ["framing", "landscape_terrain", "landscape_weather", "landscape_scenery", "lighting"],
"VEHICLE": ["framing", "vehicle_details", "lighting"],
"ANIMAL": ["framing", "animal_details", "lighting"],
"PRODUCT": ["framing", "product_details", "lighting"],
"OTHER": ["framing", "lighting"],
}
# =============================================================================
# Main Node Class
# =============================================================================
class SID_ZImagePromptGenerator(comfy_io.ComfyNode):
"""
Unified Z-Image Prompt Generator.
Automatically switches between Single-Shot and Agentic modes
based on LLM reasoning capability.
Inputs:
- image: Image to analyze
- llm_model: Connect SID_LLM_API or SID_LLM_Local
- analysis_mode: Quick/Standard/Detailed/Extreme
- preset_style: Auto-Detect/Portrait/Fashion/Artistic/NSFW
- user_guidance: Optional custom instructions
- seed: Random seed
"""
@classmethod
def define_schema(cls) -> comfy_io.Schema:
return comfy_io.Schema(
node_id="SID_ZImagePromptGenerator",
display_name="SID Z-Image Prompt Generator",
category="SID Photography Toolkit",
description="Generate Z-Image prompts. Auto-switches Single-Shot/Agentic based on LLM.",
is_output_node=True,
inputs=[
comfy_io.Image.Input(
"image",
tooltip="Input image to analyze"
),
LLM_MODEL_Type.Input(
"llm_model",
tooltip="Connect SID_LLM_API or SID_LLM_Local node"
),
comfy_io.Combo.Input(
"analysis_mode",
options=["Quick", "Standard", "Detailed", "Extreme"],
default="Standard",
tooltip="Quick=fast, Standard=balanced, Detailed=full, Extreme=maximum"
),
comfy_io.Combo.Input(
"preset_style",
options=list(PRESET_STYLES.keys()),
default="Auto-Detect",
tooltip="Focus area: Auto-Detect, Portrait, Fashion, Artistic, NSFW"
),
comfy_io.Combo.Input(
"image_resize",
options=IMAGE_RESIZE_MODES,
default="auto",
tooltip="auto=optimal for model, max=highest detail, min=fastest, original=no resize"
),
comfy_io.Combo.Input(
"prompt_length",
options=["Short", "Medium", "Long", "Free"],
default="Free",
tooltip="Short=30-60 words, Medium=80-150 words, Long=150-250 words, Free=no constraint"
),
comfy_io.String.Input(
"user_guidance",
default="",
multiline=True,
tooltip="Optional: Custom instructions (e.g., 'focus on the red dress')"
),
comfy_io.Int.Input(
"seed",
default=0,
min=0,
max=2147483647,
control_after_generate=True,
tooltip="Random seed for reproducibility"
),
],
outputs=[
comfy_io.String.Output("prompt", display_name="prompt"),
comfy_io.Int.Output("width", display_name="width"),
comfy_io.Int.Output("height", display_name="height"),
comfy_io.String.Output("metadata", display_name="metadata", tooltip="JSON metadata about the analysis (subject type, components, timing)"),
comfy_io.String.Output("debug", display_name="debug", tooltip="Debug information and raw component outputs"),
],
)
@classmethod
def execute(
cls,
image,
llm_model: LLMModelConfig,
analysis_mode: str,
preset_style: str,
image_resize: str,
prompt_length: str,
user_guidance: str,
seed: int,
) -> comfy_io.NodeOutput:
"""Execute prompt generation with auto mode selection."""
start_time = time.time()
debug_lines = []
# Get image dimensions
img_tensor = image[0]
height, width = img_tensor.shape[0], img_tensor.shape[1]
# Auto-detect pipeline based on LLM capabilities
supports_reasoning = llm_model.supports_reasoning
pipeline_type = "AGENTIC" if supports_reasoning else "SINGLE-SHOT"
def log(msg: str):
print(f"[SID-Prompt] {msg}")
debug_lines.append(msg)
log("=" * 60)
log("SID Z-Image Prompt Generator")
log("=" * 60)
log(f"Image: {width}x{height}")
log(f"Provider: {llm_model.provider} | Model: {llm_model.model}")
log(f"Analysis: {analysis_mode} | Style: {preset_style} | Length: {prompt_length}")
log(f"Resize: {image_resize} | Pipeline: {pipeline_type}")
# Initialize metadata dict
metadata_dict = {
"image_width": width,
"image_height": height,
"provider": llm_model.provider,
"model": llm_model.model,
"analysis_mode": analysis_mode,
"preset_style": preset_style,
"prompt_length": prompt_length,
"image_resize": image_resize,
"pipeline": pipeline_type,
"seed": seed,
}
try:
if supports_reasoning:
result = cls._execute_agentic(
image, llm_model, analysis_mode, preset_style, user_guidance, image_resize, prompt_length
)
else:
result = cls._execute_single_shot(
image, llm_model, analysis_mode, preset_style, user_guidance, image_resize, prompt_length
)
# Handle result (can be tuple with metadata or just prompt string)
if isinstance(result, tuple):
prompt, extra_metadata, extra_debug = result
metadata_dict.update(extra_metadata)
debug_lines.extend(extra_debug)
else:
prompt = result
# Stats
total_time = time.time() - start_time
word_count = len(prompt.split())
log(f"Generated: {word_count} words in {total_time:.1f}s")
log("=" * 60)
# Finalize metadata
metadata_dict["total_time_seconds"] = round(total_time, 2)
metadata_dict["word_count"] = word_count
# Convert to JSON strings
metadata_str = json.dumps(metadata_dict, indent=2)
debug_str = "\n".join(debug_lines)
return comfy_io.NodeOutput(prompt, width, height, metadata_str, debug_str, ui={"text": (prompt,)})
except Exception as e:
error_msg = f"Error: {str(e)}"
log(error_msg)
import traceback
traceback.print_exc()
raise RuntimeError(f"[SID_ZImagePromptGenerator] {error_msg}") from e
# =========================================================================
# SINGLE-SHOT PIPELINE
# =========================================================================
@classmethod
def _execute_single_shot(
cls,
image,
llm_model: LLMModelConfig,
analysis_mode: str,
preset_style: str,
user_guidance: str,
image_resize: str = "auto",
prompt_length: str = "Free",
) -> tuple:
"""Fast single-call prompt generation. Returns (prompt, metadata, debug_lines)."""
debug_lines = []
debug_lines.append("Single-shot mode...")
print(f"[SID-Prompt] Single-shot mode...")
# Convert image with resize
img_tensor = image[0]
base64_image, resize_info = cls._image_to_base64(img_tensor, llm_model, image_resize)
if resize_info:
debug_lines.append(resize_info)
print(f"[SID-Prompt] {resize_info}")
# Build prompts (tier-aware based on provider, with length constraints)
system_prompt = cls._build_system_prompt(llm_model.provider, preset_style, user_guidance, prompt_length)
user_prompt = cls._build_user_prompt(llm_model.provider, analysis_mode, preset_style, prompt_length)
debug_lines.append(f"System prompt length: {len(system_prompt)} chars")
debug_lines.append(f"User prompt length: {len(user_prompt)} chars")
# Get client and call
client = cls._get_client(llm_model)
pbar = comfy.utils.ProgressBar(2)
pbar.update(1)
response = cls._call_llm(client, llm_model, base64_image, system_prompt, user_prompt)
debug_lines.append(f"Raw LLM response length: {len(response)} chars")
prompt = cls._clean_output(response, llm_model.provider)
# Second LLM call: Enhance prompt with user instructions (only if instructions provided)
if user_guidance and user_guidance.strip():
debug_lines.append(f"Making second LLM call to apply user instructions...")
prompt, enhanced = cls._enhance_prompt_with_instructions(prompt, user_guidance, llm_model)
if enhanced:
debug_lines.append(f"Successfully enhanced prompt with user instructions")
else:
debug_lines.append(f"Enhancement call did not modify prompt")
pbar.update(1)
# Build metadata
metadata = {
"subject_type": "unknown", # Single-shot doesn't do subject detection
"components_analyzed": [],
"component_count": 0,
}
return prompt, metadata, debug_lines
# =========================================================================
# AGENTIC PIPELINE
# =========================================================================
@classmethod
def _execute_agentic(
cls,
image,
llm_model: LLMModelConfig,
analysis_mode: str,
preset_style: str,
user_guidance: str,
image_resize: str = "auto",
prompt_length: str = "Free",
) -> tuple:
"""Reasoning-enabled comprehensive analysis. Returns (prompt, metadata, debug_lines)."""
debug_lines = []
debug_lines.append("Agentic mode (reasoning enabled)...")
print(f"[SID-Prompt] Agentic mode (reasoning enabled)...")
# Convert image with resize
img_tensor = image[0]
base64_image, resize_info = cls._image_to_base64(img_tensor, llm_model, image_resize)
if resize_info:
debug_lines.append(resize_info)
print(f"[SID-Prompt] {resize_info}")
# Get client
client = cls._get_client(llm_model)
pbar = comfy.utils.ProgressBar(3)
# Step 1: Subject detection (if Auto-Detect)
subject_type = "WOMAN" # Default
subject_result = {}
if preset_style == "Auto-Detect":
debug_lines.append("Detecting subject...")
print(f"[SID-Prompt] Detecting subject...")
subject_result = cls._analyze_component(client, llm_model, base64_image, "subject_detection")
subject_type = subject_result.get("subject_type", "WOMAN").upper()
debug_lines.append(f"Subject detection result: {json.dumps(subject_result)}")
print(f"[SID-Prompt] Subject: {subject_type}")
pbar.update(1)
# Step 2: Get components for this mode, filtered by subject type
mode_config = ANALYSIS_MODES.get(analysis_mode, ANALYSIS_MODES["Standard"])
mode_components = mode_config["components"].copy()
# Get allowed components for this subject type
allowed_components = set(SUBJECT_COMPONENTS.get(subject_type, SUBJECT_COMPONENTS["OTHER"]))
# Filter mode components to only include those valid for this subject type
# Also add any subject-specific components that aren't in the base mode
if subject_type in ["LANDSCAPE", "VEHICLE", "ANIMAL", "PRODUCT"]:
# For non-human subjects, replace human components with subject-specific ones
components = [c for c in mode_components if c in allowed_components]
# Add subject-specific components that might not be in mode_components
subject_specific = SUBJECT_COMPONENTS.get(subject_type, [])
for sc in subject_specific:
if sc not in components and sc in COMPONENTS:
components.append(sc)
else:
# For human subjects, use mode components as-is
components = mode_components
debug_lines.append(f"Subject type: {subject_type}, analyzing {len(components)} components: {components}")
print(f"[SID-Prompt] Subject type: {subject_type}, analyzing {len(components)} components: {components}")
# Step 3: Build comprehensive agentic prompt
agentic_prompt = cls._build_agentic_prompt(components, preset_style, user_guidance, prompt_length)
pbar.update(1)
# Step 4: Single reasoning call
component_results = cls._call_reasoning_llm(client, llm_model, base64_image, agentic_prompt)
debug_lines.append(f"Raw component results: {json.dumps(component_results, indent=2)}")
# Step 5: Handle fallback for local Thinking models that output text instead of JSON
if "_raw_fallback_text" in component_results:
# JSON parsing failed, use the raw text as the prompt
raw_text = component_results["_raw_fallback_text"]
debug_lines.append(f"Using raw fallback text ({len(raw_text)} chars) - JSON parsing failed")
print(f"[SID-Prompt] Using raw fallback text as prompt ({len(raw_text)} chars)")
# Clean the raw text
prompt = cls._clean_output(raw_text, llm_model.provider)
# If user guidance, try to enhance with string-based approach
if user_guidance and user_guidance.strip():
prompt, enhanced = cls._enhance_prompt_with_instructions(prompt, user_guidance, llm_model)
if enhanced:
debug_lines.append("Enhanced fallback prompt with user instructions")
pbar.update(1)
metadata = {
"subject_type": subject_type,
"subject_detection": subject_result,
"components_analyzed": components,
"component_count": len(components),
"fallback_mode": True,
"component_results": component_results, # Contains _raw_fallback_text
}
return prompt, metadata, debug_lines
# Step 6: Assemble prompt from components
prompt = cls._assemble_prompt(component_results, mode_config, user_guidance)
# Step 7: If user guidance was provided but user_focus wasn't properly extracted,
# use second LLM call to apply the modification (same as single-shot pipeline)
if user_guidance and user_guidance.strip():
# Check if user_focus was successfully applied (prompt should start with the modification)
user_focus_raw = component_results.get("user_focus", "")
user_focus_valid = False
if isinstance(user_focus_raw, dict):
user_focus_valid = bool(user_focus_raw.get("user_focus", ""))
elif isinstance(user_focus_raw, str):
user_focus_valid = bool(user_focus_raw.strip())
if not user_focus_valid:
debug_lines.append("user_focus not found in agentic results, using enhancement call...")
print("[SID-Prompt] user_focus not found, making enhancement call...")
prompt, enhanced = cls._enhance_prompt_with_instructions(prompt, user_guidance, llm_model)
if enhanced:
debug_lines.append("Successfully enhanced prompt with user instructions")
else:
debug_lines.append("Enhancement call did not modify prompt")
pbar.update(1)
# Build metadata with component data from LLM
metadata = {
"subject_type": subject_type,
"subject_detection": subject_result,
"components_analyzed": components,
"component_count": len(components),
"component_results": component_results, # Raw data from LLM for each component
}
return prompt, metadata, debug_lines
# =========================================================================
# HELPER METHODS
# =========================================================================
@classmethod
def _image_to_base64(
cls,
img_tensor,
llm_model: LLMModelConfig,
resize_mode: str = "auto"
) -> Tuple[str, Optional[str]]:
"""
Convert image tensor to base64 with optional resizing.
Args:
img_tensor: Image tensor from ComfyUI
llm_model: LLM model configuration
resize_mode: "auto", "max", "min", or "original"
Returns:
Tuple of (base64_string, resize_info_string or None)
"""
img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
pil_image = Image.fromarray(img_np)
# Get original dimensions
height, width = img_tensor.shape[0], img_tensor.shape[1]
original_pixels = width * height
resize_info = None
# Get provider resolution limits
provider = llm_model.provider.lower()
limits = MODEL_RESOLUTION_LIMITS.get(provider, MODEL_RESOLUTION_LIMITS["default"])
# Determine target pixels based on resize mode
if resize_mode == "original":
# No resizing
target_pixels = original_pixels
elif resize_mode == "min":
# Use minimum pixels for fastest processing
target_pixels = limits["min_pixels"]
elif resize_mode == "max":
# Use maximum pixels for best detail
target_pixels = limits["max_pixels"]
else: # "auto" (default)
# Use optimal pixels for balance of quality and speed
target_pixels = limits["optimal_pixels"]
# Calculate new dimensions if resizing is needed
if resize_mode != "original" and original_pixels > target_pixels:
new_width, new_height = calculate_resize_dimensions(width, height, target_pixels)
# Only resize if there's a meaningful difference (at least 10% reduction)
if (new_width * new_height) < (original_pixels * 0.9):
pil_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
resize_info = f"Image resized: {width}x{height} -> {new_width}x{new_height} ({resize_mode} mode, {original_pixels//1000}K -> {(new_width*new_height)//1000}K pixels)"
buffer = io.BytesIO()
pil_image.save(buffer, format="JPEG", quality=95)
return base64.standard_b64encode(buffer.getvalue()).decode("utf-8"), resize_info
@classmethod
def _get_client(cls, llm_model: LLMModelConfig):
"""Get LLM client based on provider."""
provider = llm_model.provider.lower()
if provider == "anthropic":
import anthropic
import httpx
timeout = httpx.Timeout(timeout=600.0, connect=30.0)
return anthropic.Anthropic(api_key=llm_model.api_key, timeout=timeout)
elif provider in ["openai", "openai_compatible", "grok", "gemini", "groq", "together", "openrouter", "fireworks", "cerebras", "huggingface", "mistral", "deepseek", "ollama", "lmstudio", "custom"]:
from openai import OpenAI
return OpenAI(
api_key=llm_model.api_key or "not-needed",
base_url=llm_model.api_url if llm_model.api_url else None
)
elif provider == "local":
from .llm_providers.sid_llm_local import LocalModelClient
extra = llm_model.extra_params or {}
return LocalModelClient(
model_name=llm_model.model,
quantization=extra.get("quantization", "4-bit"),
device=extra.get("device", "auto"),
attention_mode=extra.get("attention_mode", "auto"),
keep_model_loaded=extra.get("keep_model_loaded", True),
top_p=extra.get("top_p", 0.9),
repetition_penalty=extra.get("repetition_penalty", 1.2),
use_torch_compile=extra.get("use_torch_compile", False),
hf_token=extra.get("hf_token"),
)
else:
raise ValueError(f"Unsupported provider: {provider}")
@classmethod
def _build_system_prompt(cls, provider: str, preset_style: str, user_guidance: str, prompt_length: str = "Free") -> str:
"""Build system prompt using TOML config based on provider tier."""
return config_loader.build_system_prompt(provider, preset_style, user_guidance, prompt_length)
@classmethod
def _build_user_prompt(cls, provider: str, analysis_mode: str, preset_style: str, prompt_length: str = "Free") -> str:
"""Build user prompt using TOML config based on provider tier and mode."""
return config_loader.build_user_prompt(provider, analysis_mode, preset_style, prompt_length)
@classmethod
def _get_stop_strings(cls, provider: str) -> List[str]:
"""Get provider-specific stop strings to prevent text leakage."""
return config_loader.get_stop_strings(provider)
@classmethod
def _call_llm(cls, client, llm_model: LLMModelConfig, base64_image: str, system_prompt: str, user_prompt: str) -> str:
"""Make LLM call with provider-specific stop strings."""
if hasattr(client, 'messages'):
# Anthropic - doesn't need stop strings, handles well
response = client.messages.create(
model=llm_model.model,
max_tokens=llm_model.max_tokens,
temperature=llm_model.temperature,
system=[{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}],
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": base64_image}},
{"type": "text", "text": user_prompt}
]
}]
)
return response.content[0].text
else:
# OpenAI-style - add stop strings for local providers
stop_strings = cls._get_stop_strings(llm_model.provider)
request_params = {
"model": llm_model.model,
"max_tokens": llm_model.max_tokens,
"temperature": llm_model.temperature,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}},
{"type": "text", "text": user_prompt}
]}
]
}
# Add stop strings if provider needs them
if stop_strings:
request_params["stop"] = stop_strings
response = client.chat.completions.create(**request_params)
# Handle empty or malformed response from LM Studio / local providers
if response is None:
raise ValueError("LLM returned None response - check if model is loaded")
if not hasattr(response, 'choices') or response.choices is None:
raise ValueError(f"LLM returned invalid response format: {response}")
if len(response.choices) == 0:
raise ValueError("LLM returned empty choices array")
if response.choices[0].message is None or response.choices[0].message.content is None:
raise ValueError("LLM returned empty message content - model may have failed to generate")
return response.choices[0].message.content
@classmethod
def _analyze_component(cls, client, llm_model: LLMModelConfig, base64_image: str, component_key: str) -> dict:
"""Analyze single component with stop strings."""
comp = COMPONENTS.get(component_key, {})
system = "You are an expert visual analyst. Analyze ONLY the specific aspect requested. Output valid JSON only."
try:
if hasattr(client, 'messages'):
response = client.messages.create(
model=llm_model.model, max_tokens=1000, temperature=0.3,
system=system,
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": base64_image}},
{"type": "text", "text": comp.get("prompt", "")}
]}]
)
text = response.content[0].text
else:
# OpenAI-style with stop strings for local providers
stop_strings = cls._get_stop_strings(llm_model.provider)
request_params = {
"model": llm_model.model,
"max_tokens": 1000,
"temperature": 0.3,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}},
{"type": "text", "text": comp.get("prompt", "")}
]}
]
}
if stop_strings:
request_params["stop"] = stop_strings
response = client.chat.completions.create(**request_params)
# Validate response from OpenAI-style API
if response is None or not hasattr(response, 'choices') or response.choices is None:
raise ValueError("LLM returned invalid response")
if len(response.choices) == 0 or response.choices[0].message is None:
raise ValueError("LLM returned empty response")
text = response.choices[0].message.content or ""
return cls._parse_json(text)
except Exception as e:
print(f"[SID-Prompt] Error analyzing {component_key}: {e}")
return {}
@classmethod
def _build_agentic_prompt(cls, components: List[str], preset_style: str, user_guidance: str, prompt_length: str = "Free") -> str:
"""Build comprehensive agentic prompt."""
comp_specs = []
for key in components:
if key in COMPONENTS:
comp = COMPONENTS[key]
comp_specs.append(f"### {comp['name']} ({key})\n{comp['prompt']}")
user_section = ""
user_focus_instruction = ""
if user_guidance and user_guidance.strip():
user_section = f"""
## USER MODIFICATION REQUEST (CRITICAL - MUST APPLY)
"{user_guidance.strip()}"
IMPORTANT: This is a MODIFICATION directive, not just a focus area. You MUST:
1. Apply this modification to ALL relevant components in your analysis
2. Describe the subject AS IF this modification is true, overriding what's literally visible
3. For clothing/nudity changes: describe the MODIFIED state, not the original
4. For other changes: incorporate the modification into your descriptions
Example: If user says "make topless" and image shows bikini, describe as topless (no bikini top).
Example: If user says "wearing red dress" and image shows blue, describe as red dress.
"""
user_focus_instruction = f"""
### User Modification Applied (user_focus)
The user requested: "{user_guidance.strip()}"
Create a description that APPLIES this modification:
1. Describe the subject AS IF the modification is already applied
2. Do NOT describe the original/visible state that contradicts the user request
3. Be specific and detailed about the modified appearance
Output JSON:
{{"user_focus": "<description with user modification APPLIED, not just acknowledged>"}}
"""
# Get length constraint
length_constraint = config_loader.get_length_constraint(prompt_length)
length_section = f"\n\n## OUTPUT LENGTH\n{length_constraint}" if length_constraint else ""
return f"""You are an expert visual analyst. Use reasoning to analyze this image comprehensively.
{user_section}
## INSTRUCTIONS
Analyze ALL components below and return a single JSON object with each component as a key.
{"REMEMBER: Apply the user's modification request to ALL relevant component descriptions!" if user_guidance else ""}
{length_section}
## COMPONENTS TO ANALYZE
{chr(10).join(comp_specs)}
{user_focus_instruction}
## OUTPUT FORMAT
Return a single JSON object:
```json
{{
"framing": {{ ... }},
"ethnicity": {{ ... }},
... etc for each component ...,
"user_focus": "<description with modification applied>" // Only if user provided guidance
}}
```"""
@classmethod
def _call_reasoning_llm(cls, client, llm_model: LLMModelConfig, base64_image: str, prompt: str) -> dict:
"""Call LLM with reasoning enabled and stop strings."""
try:
if llm_model.provider.lower() == "anthropic" and hasattr(client, 'messages'):
response = client.messages.create(
model=llm_model.model,
max_tokens=16000,
thinking={"type": "enabled", "budget_tokens": 10000},
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": base64_image}},
{"type": "text", "text": prompt}
]}]
)
text = ""
for block in response.content:
if hasattr(block, 'text'):
text = block.text
break
else:
# OpenAI-style with stop strings for local providers
stop_strings = cls._get_stop_strings(llm_model.provider)
request_params = {
"model": llm_model.model,
"max_completion_tokens": 16000,
"messages": [{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}},
{"type": "text", "text": prompt}
]}]
}
if stop_strings:
request_params["stop"] = stop_strings
response = client.chat.completions.create(**request_params)
text = response.choices[0].message.content
# Try to parse JSON
parsed = cls._parse_json(text)
# If JSON parsing failed (empty dict) and we have text, store raw text as fallback
# This helps local Thinking models that output reasoning text instead of JSON
if not parsed and text and text.strip():
raw_text = cls._clean_thinking_text(text)
# Return special dict with raw text for fallback use
print(f"[SID-Prompt] JSON parsing failed, storing {len(raw_text)} chars as fallback")
return {"_raw_fallback_text": raw_text}
return parsed
except Exception as e:
print(f"[SID-Prompt] Reasoning call error: {e}")
return {}
@classmethod
def _parse_json(cls, text: str) -> dict:
"""Parse JSON from response."""
# Try markdown code block
match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', text)
if match:
text = match.group(1)
# Try raw JSON
match = re.search(r'\{[\s\S]*\}', text)
if match:
try:
return json.loads(match.group(0))
except:
pass
return {}
@classmethod
def _clean_thinking_text(cls, text: str) -> str:
"""
Clean output from Thinking models by extracting useful content.
Handles:
- <think>...</think> tags (extract content after or inside)
- Chain-of-thought reasoning patterns
- Ensures non-empty output
"""
if not text:
return ""
original_text = text
# Method 1: Try to extract content AFTER </think> tag
think_end_match = re.search(r'</think>\s*(.+)', text, re.DOTALL | re.IGNORECASE)
if think_end_match:
after_think = think_end_match.group(1).strip()
if after_think and len(after_think) > 30:
text = after_think
else:
# Method 2: Extract from INSIDE <think> tags if nothing useful after
think_content_match = re.search(r'<think>([\s\S]*?)</think>', original_text, re.IGNORECASE)
if think_content_match:
text = think_content_match.group(1).strip()
# Method 3: Remove reasoning markers and preambles
reasoning_patterns = [
r'^(?:Let me|I will|I\'ll|First,|Now,|Okay,|Alright,)[^.]*\.\s*',
r'^(?:We are given|The user|Looking at|Based on|Given the)[^.]*\.\s*',
r'^(?:Step \d+:|Steps?:)[^\n]*\n?',
r'(?:Therefore|So|Thus|Hence|In conclusion)[,:]?\s*',
r'^\s*\d+\.\s+[^\n]*\n', # Numbered steps at start
]
for pattern in reasoning_patterns:
text = re.sub(pattern, '', text, flags=re.IGNORECASE | re.MULTILINE)
# Method 4: Look for final answer markers
final_markers = [
r'(?:Final (?:answer|prompt|description)[:\s]*)(.*)',
r'(?:Here is the (?:prompt|description)[:\s]*)(.*)',
r'(?:The (?:prompt|description) is[:\s]*)(.*)',
]
for pattern in final_markers:
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
if match and match.group(1).strip():
candidate = match.group(1).strip()
if len(candidate) > 30:
text = candidate
break
# Clean up whitespace
text = re.sub(r'\n{2,}', ' ', text)
text = re.sub(r'\s{2,}', ' ', text)
text = text.strip()
# Never return empty - use original if all cleaning failed
if not text or len(text) < 20:
# Try to find the longest sentence-like segment
sentences = re.split(r'[.!?]\s+', original_text)
if sentences:
longest = max(sentences, key=len)
if len(longest) > 20:
text = longest.strip()
else:
text = original_text.strip()
return text
@classmethod
def _assemble_prompt(cls, components: dict, mode_config: dict, user_guidance: str) -> str:
"""Assemble final prompt from components with example text filtering and user modification handling."""
sections = []
# Check if user_focus contains modification (should override clothing/body_figure)
user_focus = ""
if user_guidance and user_guidance.strip():
user_focus_raw = components.get("user_focus", "")
if isinstance(user_focus_raw, dict):
user_focus = user_focus_raw.get("user_focus", "")
elif isinstance(user_focus_raw, str):
user_focus = user_focus_raw
# Determine which components might be overridden by user modification
# Common modification targets: clothing, body_figure, intimate_apparel
override_keywords = ["topless", "nude", "naked", "dress", "wear", "outfit", "clothing", "bikini", "lingerie"]
user_guidance_lower = user_guidance.lower() if user_guidance else ""
has_clothing_modification = any(kw in user_guidance_lower for kw in override_keywords)
# Order: framing, ethnicity, face, hair, eyes, nose_lips, body_pose, body_figure, clothing, intimate, tattoos, accessories, lighting
# Also include landscape/vehicle/animal/product components
order = [
"framing", "ethnicity", "face", "hair", "eyes", "nose_lips", "body_pose", "body_figure",
"clothing", "intimate_apparel", "tattoos", "accessories", "lighting",
"landscape_terrain", "landscape_weather", "landscape_scenery",
"vehicle_details", "animal_details", "product_details"
]
# Components that user_focus might override
clothing_components = {"clothing", "intimate_apparel", "body_figure"}
for key in order:
if key in components and components[key]:
desc = components[key].get("prompt_description", "")
if desc:
# Skip clothing-related components if user guidance has clothing modification
# (user_focus will provide the modified description instead)
if has_clothing_modification and key in clothing_components and user_focus:
continue
# Clean each component description to remove any leaked examples
cleaned_desc = cls._clean_component_description(desc)
if cleaned_desc:
sections.append(cleaned_desc)
# Combine
if mode_config.get("raw_mode"):
prompt = ". ".join(s for s in sections if s)
else:
prompt = ", ".join(s for s in sections if s)
prompt = prompt.replace(", ,", ",").replace(" ", " ")
# Add user modification at the BEGINNING (highest priority)
# Note: user_focus handling is done in execute methods where we have access to llm_model
# This method now just returns the base prompt; user_focus/enhancement is handled by caller
if user_guidance and user_guidance.strip():
if user_focus and isinstance(user_focus, str) and user_focus.strip():
# Use the LLM's modified description at the start
prompt = f"{user_focus.strip()}. {prompt}"
# No else fallback here - let the caller handle enhancement
return prompt.strip()
@classmethod
def _clean_output(cls, text: str, provider: str = "default") -> str:
"""Clean LLM output using TOML config patterns."""
return config_loader.clean_output(text, provider)
@classmethod
def _clean_component_description(cls, desc: str, provider: str = "default") -> str:
"""Clean individual component description using TOML config patterns."""
if not desc:
return ""
return config_loader.clean_output(desc, provider)
@classmethod
def _enhance_prompt_with_instructions(
cls,
prompt: str,
user_guidance: str,
llm_model: LLMModelConfig,
) -> Tuple[str, bool]:
"""
Enhance/modify the generated prompt based on user instructions.
For API providers: Makes a second LLM call to intelligently rewrite the prompt.
For local vision models: Uses string-based prepending since vision models
don't work well with text-only input.
Args:
prompt: The generated prompt from the first LLM call
user_guidance: User's modification/enhancement request
llm_model: LLM model configuration
Returns:
Tuple of (enhanced_prompt, was_enhanced)
"""
if not prompt or not user_guidance or not user_guidance.strip():
return prompt, False
print(f"[SID-Prompt] Enhancing prompt with instructions: {user_guidance[:50]}...")
# For local vision models, use string-based enhancement
# Vision models don't work well with text-only LLM calls
provider_lower = llm_model.provider.lower()
if provider_lower == "local":
# Simple string-based approach: prepend the modification instruction
# and let the image generation model interpret it
enhanced = cls._apply_string_enhancement(prompt, user_guidance)
print(f"[SID-Prompt] Applied string-based enhancement for local model")
return enhanced, True
try:
client = cls._get_client(llm_model)
enhancement_system = """You are an expert prompt modifier for AI image generation.
Your task is to modify an existing image description prompt based on user instructions.
RULES:
1. Apply the user's modification to the prompt
2. Keep all other details from the original prompt
3. Remove any descriptions that conflict with the user's modification
4. Output ONLY the modified prompt text - no explanations, no markdown, no quotes
5. Maintain the same descriptive style and flow as the original
Examples:
- If user says "make topless" and prompt mentions bikini top, remove bikini top description and add "topless, bare chest"
- If user says "change hair to blonde", find hair description and change the color
- If user says "add a red dress", replace/add clothing description accordingly"""
enhancement_prompt = f"""Original prompt:
{prompt}
User modification request: {user_guidance.strip()}
Apply the user's modification to the prompt. Output ONLY the modified prompt:"""
# Make text-only LLM call (no image needed)
if hasattr(client, 'messages'):
# Anthropic
response = client.messages.create(
model=llm_model.model,
max_tokens=2000,
temperature=0.3,
system=enhancement_system,
messages=[{"role": "user", "content": enhancement_prompt}]
)
enhanced = response.content[0].text.strip()
else:
# OpenAI-style (OpenAI, Gemini, Groq, Together, etc.)
response = client.chat.completions.create(
model=llm_model.model,
max_tokens=2000,
temperature=0.3,
messages=[
{"role": "system", "content": enhancement_system},
{"role": "user", "content": enhancement_prompt}
]
)
enhanced = response.choices[0].message.content.strip()
# Clean up the response
enhanced = cls._clean_output(enhanced, llm_model.provider)
# Verify we got a valid response
if enhanced and len(enhanced) > 20:
print(f"[SID-Prompt] Enhanced prompt: {len(enhanced)} chars")
return enhanced, True
else:
print(f"[SID-Prompt] Enhancement response too short, using string-based fallback")
return cls._apply_string_enhancement(prompt, user_guidance), True
except Exception as e:
print(f"[SID-Prompt] Enhancement failed: {e}, using string-based fallback")
return cls._apply_string_enhancement(prompt, user_guidance), True
@classmethod
def _apply_string_enhancement(cls, prompt: str, user_guidance: str) -> str:
"""
Apply user guidance to prompt using string-based approach.
This is used for local vision models that can't do text-only generation,
or as a fallback when LLM enhancement fails.
The approach is to intelligently prepend or modify the prompt based on
common modification patterns.
"""
guidance_lower = user_guidance.lower().strip()
# Common clothing/appearance modifications - add at the beginning
clothing_keywords = ["wear", "dress", "outfit", "clothing", "topless", "nude",
"naked", "bikini", "lingerie", "shirt", "pants", "skirt"]
color_keywords = ["yellow", "red", "blue", "green", "black", "white", "pink",
"purple", "orange", "color", "colour"]
hair_keywords = ["hair", "blonde", "brunette", "redhead", "braid", "ponytail"]
# Check if this is a clothing/color modification
is_clothing_mod = any(kw in guidance_lower for kw in clothing_keywords)
is_color_mod = any(kw in guidance_lower for kw in color_keywords)
is_hair_mod = any(kw in guidance_lower for kw in hair_keywords)
if is_clothing_mod or is_color_mod:
# For clothing/color mods, extract the key modification
mod_text = user_guidance.strip()
# Convert instruction to descriptive form by removing common prefixes
prefixes_to_remove = [
"make ", "change to ", "convert to ",
"make the ", "change the ", "convert the ",
"make it ", "change it to ", "convert it to ",
]
mod_lower = mod_text.lower()
for prefix in prefixes_to_remove:
if mod_lower.startswith(prefix):
mod_text = mod_text[len(prefix):]
mod_lower = mod_text.lower()
break
# Handle "X into Y" patterns (e.g., "dress into yellow colour")
if " into " in mod_lower:
# Extract what's after "into" as the key descriptor
parts = mod_text.split(" into ", 1)
if len(parts) == 2:
# "dress into yellow colour" -> "yellow colour dress"
subject = parts[0].strip()
descriptor = parts[1].strip()
mod_text = f"{descriptor} {subject}"
# Build modified prompt
return f"{mod_text}, {prompt}"
elif is_hair_mod:
# For hair mods, extract the modification
mod_text = user_guidance.strip()
prefixes_to_remove = ["make ", "make the ", "change to ", "change the "]
mod_lower = mod_text.lower()
for prefix in prefixes_to_remove:
if mod_lower.startswith(prefix):
mod_text = mod_text[len(prefix):]
break
return f"with {mod_text}, {prompt}"
else:
# General case: prepend as a style/modification directive
return f"{user_guidance.strip()}, {prompt}"