- core/strategies/surreal: eight generator modes taken from how Magritte, Ernst, Dali, Varo, Uelsmann, Atget and Man Ray worked, feeding one critic (seeded code, or one LLM call). One anomaly stated as plain physical fact against a credible world, chosen for affinity rather than distance; count capped at 2. Output: brief, instruction block, why trace, title, counter-terms. - Surreal Premise node; plugs into every enhancer's wildcard_instructions. Anomaly survival is measurable with the Concept Survival Check. - The surreal preset (video nodes) and genre_style = surreal (Text-to-Image) route through the engine instead of style words; fail-soft to the old text. - Lists in wildcards/surreal, personal iconography.yaml template, docs/SURREAL_GUIDE.md, dev/verify_surreal.py (78 checks). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VpCi6nw5UECMdZ7ayYJvN
3795 lines
164 KiB
Python
3795 lines
164 KiB
Python
"""
|
||
Text-to-Image Prompt Enhancer Node
|
||
Advanced multi-platform prompt enhancement for image generation
|
||
Supports: SDXL, Pony, Illustrious, Flux, Chroma, Qwen-Image, Qwen-Edit, Wan-Image
|
||
"""
|
||
|
||
import torch
|
||
import numpy as np
|
||
from PIL import Image
|
||
import io
|
||
import random
|
||
import re
|
||
from typing import Tuple, Optional, Dict, List, Any, Union
|
||
from .llm_backend import LLMBackend
|
||
from .qwen3_vl_backend import caption_with_qwen3_vl
|
||
from .platforms import get_platform_config, get_negative_prompt_for_platform
|
||
from .profile_support import extended_choices
|
||
from .wildcard_support import (
|
||
WILDCARD_INSTRUCTIONS_INPUT,
|
||
WILDCARD_LLM_BLOCK_INPUT,
|
||
enforce_verbatim,
|
||
llm_block,
|
||
report_lines,
|
||
resolve_wildcards,
|
||
)
|
||
from .utils import save_prompts_to_file, parse_keywords
|
||
|
||
|
||
class TextToImagePromptEnhancer:
|
||
"""
|
||
Advanced text-to-image prompt enhancement with platform-specific optimization
|
||
|
||
Features:
|
||
- Multiple platform support with tailored prompting
|
||
- Optional image reference input (1-2 images)
|
||
- Advanced aesthetic controls (lighting, camera, time of day, etc.)
|
||
- Wildcard random options
|
||
- Platform-specific formatting and token optimization
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.type = "text_to_image_enhancement"
|
||
self.output_dir = "output/txt2img_prompts"
|
||
|
||
# Wildcard options
|
||
self.camera_angles = [
|
||
"eye level", "low angle", "high angle", "dutch angle", "bird's eye view",
|
||
"worm's eye view", "over the shoulder", "point of view", "extreme close-up angle"
|
||
]
|
||
|
||
self.lighting_sources = [
|
||
"natural sunlight", "studio lighting", "golden hour sun", "moonlight",
|
||
"candlelight", "neon lights", "firelight", "spotlight", "ambient lighting",
|
||
"backlight", "rim lighting", "window light", "street lights"
|
||
]
|
||
|
||
self.lighting_quality = [
|
||
"soft diffused", "hard dramatic", "even balanced", "high contrast",
|
||
"low key", "high key", "chiaroscuro", "volumetric", "atmospheric"
|
||
]
|
||
|
||
self.times_of_day = [
|
||
"dawn", "early morning", "mid-morning", "noon", "afternoon",
|
||
"golden hour", "dusk", "twilight", "night", "midnight", "blue hour"
|
||
]
|
||
|
||
self.weather_conditions = [
|
||
"clear sky", "partly cloudy", "overcast", "misty", "foggy",
|
||
"rainy", "stormy", "snowy", "sunny", "hazy"
|
||
]
|
||
|
||
self.composition_styles = [
|
||
"rule of thirds", "centered", "symmetrical", "asymmetrical",
|
||
"golden ratio", "leading lines", "frame within frame", "negative space",
|
||
"balanced", "dynamic diagonal"
|
||
]
|
||
|
||
self.genre_styles = [
|
||
"surreal", "cinematic", "dramatic", "action", "humorous",
|
||
"indie", "horror", "scifi", "romantic", "artistic",
|
||
"documentary", "minimalist", "maximalist", "vintage", "modern",
|
||
"fantasy", "noir", "cyberpunk", "steampunk", "dieselpunk",
|
||
"mythic", "gothic", "art deco", "retro futurism"
|
||
]
|
||
|
||
self.historical_periods = [
|
||
"prehistoric era", "ancient civilizations", "classical antiquity",
|
||
"medieval era", "renaissance", "baroque period", "industrial revolution",
|
||
"victorian era", "edwardian era", "roaring twenties", "mid-century modern",
|
||
"1960s counterculture", "1980s neon wave", "1990s digital dawn",
|
||
"modern day", "near future", "far future", "cyberpunk future",
|
||
"post-apocalyptic era", "fantasy realm", "science fiction epoch"
|
||
]
|
||
|
||
self.subject_framings = [
|
||
"extreme close-up", "close-up", "medium close-up",
|
||
"medium shot", "medium wide", "wide shot",
|
||
"full body", "cowboy shot", "bust shot",
|
||
"head and shoulders", "three-quarter", "establishing shot",
|
||
"aerial overview", "profile view"
|
||
]
|
||
|
||
self.subject_poses = [
|
||
"standing", "sitting", "lying down", "kneeling", "crouching",
|
||
"action pose", "portrait pose", "dynamic", "static",
|
||
"asymmetric", "contrapposto", "relaxed", "tense",
|
||
"walking", "running", "jumping", "dancing", "floating"
|
||
]
|
||
|
||
self.creative_randomness_modes = {
|
||
"off": "Stay close to user wording with minimal embellishment.",
|
||
"subtle": "Add gentle flourishes that enhance mood without changing subject focus.",
|
||
"moderate": "Introduce fresh context, supporting details, or small narrative beats.",
|
||
"bold": "Transform the prompt into a vivid scene with new narrative hooks and world-building.",
|
||
"storyteller": "Invent an imaginative mini-story or scenario that surprises while honoring the subject.",
|
||
"chaotic": "Push boundaries with experimental, dreamlike, or surreal twists."
|
||
}
|
||
|
||
self.random_story_settings = [
|
||
"abandoned futuristic metropolis", "misty forest crossroads",
|
||
"luminous underwater research lab", "quiet suburban street at dawn",
|
||
"ancient floating temple", "deserted lunar colony", "ornate victorian ballroom",
|
||
"hidden speakeasy behind a bookstore", "glitching neon arcade",
|
||
"forgotten museum of impossible inventions", "bioluminescent cavern",
|
||
"storm-lashed airship deck", "sunset-drenched mountain pass",
|
||
"labyrinthine library of living books", "gravity-defying market square"
|
||
]
|
||
|
||
self.random_story_companions = [
|
||
"a time-traveling archivist", "an eccentric inventor", "a sentient automaton",
|
||
"a mysterious stranger in vintage attire", "a playful cosmic entity",
|
||
"a band of skyship pirates", "a chorus of bioluminescent sprites",
|
||
"a rival artist seeking inspiration", "a loyal cybernetic fox",
|
||
"an undercover interstellar diplomat"
|
||
]
|
||
|
||
self.random_story_conflicts = [
|
||
"searching for the last fragment of a lost melody",
|
||
"racing against an impending temporal storm",
|
||
"unlocking a doorway hidden inside a beam of light",
|
||
"negotiating peace between rival realities",
|
||
"decoding whispers carried by the rain",
|
||
"restoring color to a world frozen in monochrome",
|
||
"solving a puzzle etched into the constellations",
|
||
"protecting a fragile artifact of collective memories"
|
||
]
|
||
|
||
self.reference_usage_labels = {
|
||
"caption": "scene overview",
|
||
"style": "artistic style and texture",
|
||
"lighting": "lighting qualities and direction",
|
||
"genre": "genre or mood cues",
|
||
"time_period": "time period context",
|
||
"subject": "primary subject appearance",
|
||
"objects": "notable secondary objects",
|
||
"color": "color palette or grading",
|
||
"composition": "framing and spatial layout"
|
||
}
|
||
|
||
self.reference_directive_choices = [
|
||
"none",
|
||
"auto",
|
||
"recreate",
|
||
"reinterpret",
|
||
"subject only",
|
||
"style only",
|
||
"lighting only",
|
||
"composition",
|
||
"genre"
|
||
]
|
||
|
||
self._seed_state: Dict[str, Any] = {
|
||
"last_seed": None,
|
||
"last_mode": None,
|
||
"last_input": None
|
||
}
|
||
|
||
self.reference_directive_configs = {
|
||
"none": {
|
||
"display": "None",
|
||
"user_guidance": "Use this reference for broad inspiration using its full caption.",
|
||
"user_summary": "Incorporate helpful elements from the reference caption without enforcing a specific focus.",
|
||
"include_categories": ["caption"],
|
||
"exclude_categories": [],
|
||
"llm_instruction": (
|
||
"Absorb the entire reference caption as general inspiration. Keep the meaningful traits but avoid over-emphasizing any single element."
|
||
),
|
||
"analysis_focus": (
|
||
"Summarize the overall subject, setting, lighting, mood, and notable items so the prompt can mirror the scene holistically."
|
||
)
|
||
},
|
||
"auto": {
|
||
"display": "Auto",
|
||
"user_guidance": "Adapt helpful traits from the reference based on context.",
|
||
"user_summary": "Auto-balances useful cues from the reference.",
|
||
"include_categories": [
|
||
"caption",
|
||
"style",
|
||
"lighting",
|
||
"color",
|
||
"genre",
|
||
"composition",
|
||
"subject",
|
||
"objects",
|
||
"time_period"
|
||
],
|
||
"exclude_categories": [],
|
||
"llm_instruction": (
|
||
"Absorb the full caption from this reference to strengthen the user's brief. "
|
||
"Borrow cues that align with the goal and adjust anything that conflicts."
|
||
),
|
||
"analysis_focus": (
|
||
"Identify the most influential subject, style, lighting, composition, and supporting details from the"
|
||
" complete caption that will benefit the final prompt."
|
||
)
|
||
},
|
||
"recreate": {
|
||
"display": "Recreate",
|
||
"user_guidance": "Match the reference closely across subject, palette, lighting, and mood.",
|
||
"user_summary": "Recreate the reference look as faithfully as possible.",
|
||
"include_categories": "all",
|
||
"exclude_categories": [],
|
||
"llm_instruction": (
|
||
"Reproduce this reference almost verbatim. Maintain subject identity, palette, lighting, composition, "
|
||
"and supporting props unless the main prompt explicitly overrides them."
|
||
),
|
||
"analysis_focus": (
|
||
"List the critical traits that must be preserved exactly: subject identity, pose, palette, lighting,"
|
||
" composition, and key props or environment cues."
|
||
)
|
||
},
|
||
"reinterpret": {
|
||
"display": "Reinterpret",
|
||
"user_guidance": "Use the reference as inspiration; preserve core subject or mood, but embrace smart variations.",
|
||
"user_summary": "Keep the spirit of the reference while allowing tasteful changes.",
|
||
"include_categories": [
|
||
"caption",
|
||
"subject",
|
||
"objects",
|
||
"genre",
|
||
"style",
|
||
"lighting",
|
||
"color",
|
||
"composition",
|
||
"time_period"
|
||
],
|
||
"exclude_categories": [],
|
||
"llm_instruction": (
|
||
"Preserve the recognizable subject or atmosphere from this reference using the full caption as context, yet "
|
||
"refresh style, palette, or composition when it improves alignment with the user's prompt."
|
||
),
|
||
"analysis_focus": (
|
||
"Summarize the anchor traits (subject identity, mood, palette cues) drawn from the entire caption while"
|
||
" noting which aspects feel flexible for reinterpretation."
|
||
)
|
||
},
|
||
"subject_only": {
|
||
"display": "Subject Only",
|
||
"user_guidance": "Preserve the subject’s identity, pose, and defining traits; let other elements follow the prompt.",
|
||
"user_summary": "Keep subject fidelity; redesign other aspects.",
|
||
"include_categories": ["caption", "subject", "objects", "composition"],
|
||
"exclude_categories": ["style", "lighting", "color", "genre"],
|
||
"llm_instruction": (
|
||
"Carry over the subject's identity, pose, and silhouette from this reference using the full caption as context. "
|
||
"Invent fresh style, lighting, and background details to suit the user's prompt."
|
||
),
|
||
"analysis_focus": (
|
||
"Describe the subject's appearance, pose, silhouette, distinguishing features, and supportive forms as captured"
|
||
" in the caption so the identity remains unmistakable."
|
||
)
|
||
},
|
||
"style_only": {
|
||
"display": "Style Only",
|
||
"user_guidance": "Borrow the artistic style, texture, palette, and brushwork; ignore subject and composition cues.",
|
||
"user_summary": "Transfer the reference style while changing subject matter.",
|
||
"include_categories": ["caption", "style", "color", "genre", "lighting"],
|
||
"exclude_categories": ["subject", "objects", "composition"],
|
||
"llm_instruction": (
|
||
"Adopt the artistic style, texture, and palette suggested by this reference after studying the complete caption. "
|
||
"Replace its subject and layout with the user's requested scene."
|
||
),
|
||
"analysis_focus": (
|
||
"Detail the artistic medium, techniques, palette tendencies, brushwork, texture, and stylistic motifs present"
|
||
" in the caption so the style can be transferred accurately."
|
||
)
|
||
},
|
||
"lighting_only": {
|
||
"display": "Lighting Only",
|
||
"user_guidance": "Replicate lighting qualities like direction, intensity, and warmth; disregard subject or style cues.",
|
||
"user_summary": "Reuse lighting mood without copying subject matter.",
|
||
"include_categories": ["caption", "lighting", "color"],
|
||
"exclude_categories": ["subject", "objects", "style", "genre", "composition"],
|
||
"llm_instruction": (
|
||
"Match the lighting direction, intensity, and warmth suggested by this reference while treating the full caption as context. "
|
||
"Do not replicate its subject, style, or composition."
|
||
),
|
||
"analysis_focus": (
|
||
"Explain the lighting direction, intensity, quality, shadow behavior, and color temperature found in the caption"
|
||
" so the mood can be recreated without losing context."
|
||
)
|
||
},
|
||
"composition": {
|
||
"display": "Composition",
|
||
"user_guidance": "Adopt the camera angle, framing, and spatial layout; let subject and style come from the prompt.",
|
||
"user_summary": "Mirror the reference framing while refreshing other traits.",
|
||
"include_categories": ["composition", "caption", "objects"],
|
||
"exclude_categories": ["style", "lighting", "color", "genre"],
|
||
"llm_instruction": (
|
||
"Borrow the framing, camera angle, and spatial relationships implied by this reference using the caption as your map. "
|
||
"Populate that layout with the subjects and styling requested in the prompt."
|
||
),
|
||
"analysis_focus": (
|
||
"Describe the camera angle, focal length impression, framing, depth relationships, and placement of key elements"
|
||
" from the caption so the layout can be preserved."
|
||
)
|
||
},
|
||
"genre": {
|
||
"display": "Genre",
|
||
"user_guidance": "Match the genre or storytelling conventions; let other specifics follow the prompt unless genre demands otherwise.",
|
||
"user_summary": "Carry over the reference genre atmosphere.",
|
||
"include_categories": ["caption", "genre", "style", "color", "lighting"],
|
||
"exclude_categories": ["subject", "objects", "composition"],
|
||
"llm_instruction": (
|
||
"Preserve the genre tone and storytelling conventions from this reference by internalizing the whole caption. "
|
||
"Let the user's prompt determine subject matter and layout unless genre cues require tweaks."
|
||
),
|
||
"analysis_focus": (
|
||
"Summarize the genre-defining mood, atmosphere, storytelling motifs, and stylistic cues from the full caption"
|
||
" so the tone carries over cleanly."
|
||
)
|
||
}
|
||
}
|
||
|
||
self.default_reference_analysis_method = "sequential_refine"
|
||
self.reference_guardrail_text = (
|
||
"Blend reference guidance without repeating yourself. Do not mention source image dimensions, aspect ratios, "
|
||
"or pixel resolutions under any circumstance."
|
||
)
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
# Core inputs
|
||
"text_prompt": ("STRING", {
|
||
"multiline": True,
|
||
"default": "a beautiful woman in a garden",
|
||
"placeholder": "Describe the image you want to generate\n\n"
|
||
"Supports:\n"
|
||
"- Emphasis: (keyword:1.5) to increase weight\n"
|
||
"- De-emphasis: (keyword:0.5) to decrease weight\n"
|
||
"- Alternation: {apple|banana|orange} picks one randomly\n"
|
||
"- Nested: {red|blue|green} (dress:1.2) works too"
|
||
}),
|
||
|
||
"reference_directive_1": ([
|
||
"none",
|
||
"auto",
|
||
"recreate",
|
||
"reinterpret",
|
||
"subject only",
|
||
"style only",
|
||
"lighting only",
|
||
"composition",
|
||
"genre"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"reference_directive_2": ([
|
||
"none",
|
||
"auto",
|
||
"recreate",
|
||
"reinterpret",
|
||
"subject only",
|
||
"style only",
|
||
"lighting only",
|
||
"composition",
|
||
"genre"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"prompt_context": ([
|
||
"none",
|
||
"auto",
|
||
"expand_short_prompt",
|
||
"finish_opening_line",
|
||
"prompt_from_item_list",
|
||
"modify_reference_image",
|
||
"enhance_reference_image"
|
||
], {
|
||
"default": "none",
|
||
"tooltip": (
|
||
"none/auto: Standard prompt\n"
|
||
"expand_short_prompt: Expand shorthand into full description\n"
|
||
"finish_opening_line: Continue from your opening line\n"
|
||
"prompt_from_item_list: Transform comma-separated elements into scene\n"
|
||
"modify_reference_image: Alter specific parts of reference\n"
|
||
"enhance_reference_image: Enrich reference with new elements"
|
||
)
|
||
}),
|
||
|
||
"creative_randomness": ([
|
||
"auto", "none", "off", "subtle", "moderate", "bold", "storyteller", "chaotic"
|
||
], {
|
||
"default": "none",
|
||
"tooltip": (
|
||
"off/none: Minimal embellishment\n"
|
||
"subtle: Gentle mood enhancement\n"
|
||
"moderate: Add supporting details\n"
|
||
"bold: Vivid scene with narrative hooks\n"
|
||
"storyteller: Imaginative mini-story\n"
|
||
"chaotic: Experimental, surreal twists"
|
||
)
|
||
}),
|
||
|
||
# Platform selection
|
||
"target_platform": (extended_choices([
|
||
"flux",
|
||
"flux_kontex",
|
||
"sd_xl",
|
||
"sd_1_5",
|
||
"pony",
|
||
"illustrious",
|
||
"chroma",
|
||
"pixart_sigma",
|
||
"aura_flow",
|
||
"noobai",
|
||
"kolors",
|
||
"qwen_image",
|
||
"qwen_image_edit",
|
||
"wan_image"
|
||
], ("image", "edit")), {
|
||
"default": "flux"
|
||
}),
|
||
|
||
# LLM settings
|
||
"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
|
||
}),
|
||
|
||
"vision_backend": ([
|
||
"auto",
|
||
"lm_studio",
|
||
"ollama",
|
||
"qwen3_vl",
|
||
"disable"
|
||
], {
|
||
"default": "auto",
|
||
"tooltip": "Vision captioning: auto=inherit from main LLM, qwen3_vl=local Qwen3-VL, lm_studio/ollama=separate vision model, disable=heuristics only"
|
||
}),
|
||
|
||
"vision_api_endpoint": ("STRING", {
|
||
"default": "http://localhost:1234/v1",
|
||
"multiline": False,
|
||
"tooltip": (
|
||
"Vision backend API endpoint (lm_studio/ollama) or custom model path (qwen3_vl)\n"
|
||
"For qwen3_vl: Leave default for auto-detect, or specify 'A:\\path\\to\\model'"
|
||
)
|
||
}),
|
||
|
||
# Camera & Composition
|
||
"camera_angle": ([
|
||
"auto", "random", "none",
|
||
"eye level", "low angle", "high angle", "dutch angle",
|
||
"bird's eye view", "worm's eye view", "over the shoulder",
|
||
"point of view", "extreme close-up angle"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"composition": ([
|
||
"auto", "random", "none",
|
||
"rule of thirds", "centered", "symmetrical", "asymmetrical",
|
||
"golden ratio", "leading lines", "frame within frame",
|
||
"negative space", "balanced", "dynamic diagonal"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
# Lighting
|
||
"lighting_source": ([
|
||
"auto", "random", "none",
|
||
"natural sunlight", "studio lighting", "golden hour sun",
|
||
"moonlight", "candlelight", "neon lights", "firelight",
|
||
"spotlight", "ambient lighting", "backlight", "rim lighting",
|
||
"window light", "street lights"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"lighting_quality": ([
|
||
"auto", "random", "none",
|
||
"soft diffused", "hard dramatic", "even balanced",
|
||
"high contrast", "low key", "high key", "chiaroscuro",
|
||
"volumetric", "atmospheric"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
# Time & Weather
|
||
"time_of_day": ([
|
||
"auto", "random", "none",
|
||
"dawn", "early morning", "mid-morning", "noon", "afternoon",
|
||
"golden hour", "dusk", "twilight", "night", "midnight", "blue hour"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"historical_period": ([
|
||
"auto", "random", "none",
|
||
"prehistoric era", "ancient civilizations", "classical antiquity",
|
||
"medieval era", "renaissance", "baroque period", "industrial revolution",
|
||
"victorian era", "edwardian era", "roaring twenties", "mid-century modern",
|
||
"1960s counterculture", "1980s neon wave", "1990s digital dawn",
|
||
"modern day", "near future", "far future", "cyberpunk future",
|
||
"post-apocalyptic era", "fantasy realm", "science fiction epoch"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"weather": ([
|
||
"auto", "random", "none",
|
||
"clear sky", "partly cloudy", "overcast", "misty", "foggy",
|
||
"rainy", "stormy", "snowy", "sunny", "hazy"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
# Style & Quality
|
||
"art_style": ([
|
||
"auto", "none",
|
||
"photorealistic", "digital art", "oil painting", "watercolor",
|
||
"anime", "manga", "sketch", "pencil drawing", "3D render",
|
||
"illustration", "concept art", "impressionist", "abstract",
|
||
"pixel art", "low poly", "papercraft", "isometric"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"genre_style": ([
|
||
"auto", "random", "none",
|
||
"surreal", "cinematic", "dramatic", "action", "humorous",
|
||
"indie", "horror", "scifi", "romantic", "x-rated", "pg",
|
||
"artistic", "documentary", "minimalist", "maximalist",
|
||
"vintage", "modern", "fantasy", "noir", "cyberpunk",
|
||
"steampunk", "dieselpunk", "mythic", "gothic", "art deco", "retro futurism"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"color_mood": ([
|
||
"auto", "random", "none",
|
||
"vibrant", "muted", "monochrome", "warm tones", "cool tones",
|
||
"pastel", "high contrast", "desaturated", "neon", "earth tones"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
# Subject Controls
|
||
"subject_framing": ([
|
||
"auto", "random", "none",
|
||
"extreme close-up", "close-up", "medium close-up",
|
||
"medium shot", "medium wide", "wide shot",
|
||
"full body", "cowboy shot", "bust shot",
|
||
"head and shoulders", "three-quarter", "establishing shot",
|
||
"aerial overview", "profile view"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
"subject_pose": ([
|
||
"auto", "random", "none",
|
||
"standing", "sitting", "lying down", "kneeling", "crouching",
|
||
"action pose", "portrait pose", "dynamic", "static",
|
||
"asymmetric", "contrapposto", "relaxed", "tense",
|
||
"walking", "running", "jumping", "dancing", "floating"
|
||
], {
|
||
"default": "none"
|
||
}),
|
||
|
||
# Keywords
|
||
"positive_keywords": ("STRING", {
|
||
"default": "",
|
||
"multiline": True,
|
||
"placeholder": "Additional keywords, LoRA triggers, specific details"
|
||
}),
|
||
|
||
"negative_keywords": ("STRING", {
|
||
"default": "ugly, blurry, duplicate, deformed, distorted, lowres, bad anatomy, disfigured, poorly drawn, mutation, mutated, extra limbs, cloned face, disfigured, gross proportions, malformed limbs, missing arms, missing legs, fused fingers, too many fingers, long neck",
|
||
"multiline": True,
|
||
"placeholder": "Things to avoid"
|
||
}),
|
||
|
||
"seed_mode": ([
|
||
"random",
|
||
"fixed",
|
||
"increment",
|
||
"decrement"
|
||
], {
|
||
"default": "random"
|
||
}),
|
||
|
||
"random_seed": ("INT", {
|
||
"default": -1,
|
||
"min": -1,
|
||
"max": 2_147_483_647,
|
||
"step": 1
|
||
}),
|
||
|
||
# Output
|
||
"save_to_file": ("BOOLEAN", {
|
||
"default": False
|
||
}),
|
||
|
||
"filename_base": ("STRING", {
|
||
"default": "txt2img_prompt",
|
||
"multiline": False
|
||
})
|
||
},
|
||
"optional": {
|
||
# Optional image references
|
||
"reference_image_1": ("IMAGE",),
|
||
"reference_image_2": ("IMAGE",),
|
||
"reference_caption_override_1": ("STRING", {
|
||
"default": "",
|
||
"multiline": True,
|
||
"placeholder": "Optional external caption for Reference 1"
|
||
}),
|
||
"reference_caption_override_2": ("STRING", {
|
||
"default": "",
|
||
"multiline": True,
|
||
"placeholder": "Optional external caption for Reference 2"
|
||
}),
|
||
# New inputs are only ever APPENDED below this line, so saved workflows keep their widget order.
|
||
# Wildcards use this node's own random_seed / seed_mode, so no separate wildcard seed is needed.
|
||
"wildcard_llm_block": WILDCARD_LLM_BLOCK_INPUT,
|
||
"wildcard_instructions": WILDCARD_INSTRUCTIONS_INPUT,
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "INT")
|
||
RETURN_NAMES = ("positive_prompt", "negative_prompt", "settings_used", "status", "vision_caption", "seed_used")
|
||
|
||
FUNCTION = "enhance_prompt"
|
||
CATEGORY = "Eric Prompt Enhancers"
|
||
OUTPUT_NODE = True
|
||
|
||
@classmethod
|
||
def IS_CHANGED(cls, random_seed: int, seed_mode: str, **kwargs):
|
||
"""Determine if the node should re-execute based on seed mode.
|
||
|
||
Returns a unique value for modes that need to re-run each time,
|
||
or the seed value for fixed mode (to use ComfyUI's caching).
|
||
"""
|
||
import time
|
||
|
||
mode = str(seed_mode).strip().lower()
|
||
|
||
# For random/increment/decrement, return unique value each time to force re-execution
|
||
if mode in {"random", "increment", "decrement"}:
|
||
# Return timestamp to guarantee uniqueness
|
||
return float(time.time())
|
||
|
||
# For fixed mode, return the seed value so ComfyUI can cache
|
||
try:
|
||
seed_value = int(random_seed)
|
||
if seed_value < 0:
|
||
# Negative seed means random even in fixed mode
|
||
return float(time.time())
|
||
return seed_value
|
||
except Exception:
|
||
return float(time.time())
|
||
|
||
def enhance_prompt(
|
||
self,
|
||
text_prompt: str,
|
||
reference_directive_1: str,
|
||
reference_directive_2: str,
|
||
prompt_context: str,
|
||
target_platform: str,
|
||
llm_backend: str,
|
||
api_endpoint: str,
|
||
temperature: float,
|
||
vision_backend: str,
|
||
vision_api_endpoint: str,
|
||
camera_angle: str,
|
||
composition: str,
|
||
lighting_source: str,
|
||
lighting_quality: str,
|
||
time_of_day: str,
|
||
historical_period: str,
|
||
weather: str,
|
||
art_style: str,
|
||
genre_style: str,
|
||
color_mood: str,
|
||
creative_randomness: str,
|
||
subject_framing: str,
|
||
subject_pose: str,
|
||
positive_keywords: str,
|
||
negative_keywords: str,
|
||
seed_mode: str,
|
||
random_seed: int,
|
||
save_to_file: bool,
|
||
filename_base: str,
|
||
reference_image_1: Optional[torch.Tensor] = None,
|
||
reference_image_2: Optional[torch.Tensor] = None,
|
||
reference_caption_override_1: str = "",
|
||
reference_caption_override_2: str = "",
|
||
wildcard_llm_block: bool = True,
|
||
wildcard_instructions: str = ""
|
||
) -> Tuple[str, str, str, str]:
|
||
"""Main processing function"""
|
||
try:
|
||
requested_seed = int(random_seed)
|
||
except Exception:
|
||
requested_seed = -1
|
||
|
||
seed_mode_normalized = str(seed_mode).strip().lower()
|
||
seed_value, resolved_seed_mode = self._resolve_seed_value(seed_mode_normalized, requested_seed)
|
||
|
||
python_random_state = random.getstate()
|
||
numpy_random_state = np.random.get_state()
|
||
|
||
random.seed(seed_value)
|
||
np.random.seed(seed_value % (2**32))
|
||
|
||
try:
|
||
# STEP 0: Resolve wildcards and {a|b} alternations first (before LLM) with the shared
|
||
# engine (core/wildcards), seeded by this node's resolved seed so "fixed" mode reproduces.
|
||
wildcard_resolution = resolve_wildcards(
|
||
text_prompt, seed=seed_value, legacy=True, upstream_instructions=wildcard_instructions
|
||
)
|
||
text_prompt = wildcard_resolution.text
|
||
|
||
# STEP 0.5: Protect emphasis syntax
|
||
text_prompt = self._preserve_emphasis_syntax(text_prompt)
|
||
|
||
# STEP 1: Process reference images if provided
|
||
reference_images = []
|
||
directive_inputs = []
|
||
override_1 = (reference_caption_override_1 or "").strip()
|
||
override_2 = (reference_caption_override_2 or "").strip()
|
||
|
||
if reference_image_1 is not None:
|
||
reference_images.append({
|
||
"label": "Reference 1",
|
||
"tensor": reference_image_1,
|
||
"caption_override": override_1,
|
||
"override_source": "user" if override_1 else None
|
||
})
|
||
directive_inputs.append(("Reference 1", reference_directive_1))
|
||
elif override_1:
|
||
# Allow caption-only references when external descriptor provided
|
||
reference_images.append({
|
||
"label": "Reference 1",
|
||
"tensor": None,
|
||
"caption_override": override_1,
|
||
"override_source": "user"
|
||
})
|
||
directive_inputs.append(("Reference 1", reference_directive_1))
|
||
|
||
if reference_image_2 is not None:
|
||
reference_images.append({
|
||
"label": "Reference 2",
|
||
"tensor": reference_image_2,
|
||
"caption_override": override_2,
|
||
"override_source": "user" if override_2 else None
|
||
})
|
||
directive_inputs.append(("Reference 2", reference_directive_2))
|
||
elif override_2:
|
||
reference_images.append({
|
||
"label": "Reference 2",
|
||
"tensor": None,
|
||
"caption_override": override_2,
|
||
"override_source": "user"
|
||
})
|
||
directive_inputs.append(("Reference 2", reference_directive_2))
|
||
|
||
reference_plan = self._prepare_reference_plan(directive_inputs)
|
||
|
||
# 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
|
||
)
|
||
|
||
vision_backend_selection = (vision_backend or "inherit").strip().lower()
|
||
if not vision_backend_selection:
|
||
vision_backend_selection = "auto"
|
||
|
||
# Vision backend now supports custom endpoint via vision_api_endpoint
|
||
|
||
vision_llm: Optional[LLMBackend] = None
|
||
vision_qwen_config: Optional[Dict[str, Any]] = None
|
||
vision_backend_mode = "disabled"
|
||
vision_caption_enabled = False
|
||
vision_model_used = ""
|
||
vision_init_warning: Optional[str] = None
|
||
|
||
if vision_backend_selection in {"", "auto", "inherit"}:
|
||
if llm.supports_images():
|
||
vision_llm = llm
|
||
vision_backend_mode = "inherit"
|
||
vision_caption_enabled = True
|
||
vision_model_used = getattr(llm, "model_name", "auto-detected")
|
||
else:
|
||
vision_backend_mode = "disabled"
|
||
elif vision_backend_selection == "disable":
|
||
vision_backend_mode = "disabled"
|
||
elif vision_backend_selection in {"lm_studio", "ollama"}:
|
||
# Use vision-specific endpoint
|
||
try:
|
||
vision_llm = LLMBackend(
|
||
backend_type=vision_backend_selection,
|
||
endpoint=vision_api_endpoint,
|
||
model_name=None, # Auto-detect
|
||
temperature=temperature
|
||
)
|
||
if vision_llm.supports_images():
|
||
vision_backend_mode = vision_backend_selection
|
||
vision_caption_enabled = True
|
||
vision_model_used = getattr(vision_llm, "model_name", "auto-detected")
|
||
else:
|
||
vision_backend_mode = "disabled"
|
||
vision_init_warning = (
|
||
f"Vision backend '{vision_backend_selection}' does not advertise image support."
|
||
)
|
||
except Exception as exc:
|
||
vision_backend_mode = "disabled"
|
||
vision_init_warning = (
|
||
f"Vision backend init failed ({vision_backend_selection}): {exc}"
|
||
)
|
||
print(f"[Text-to-Image] ⚠️ Vision backend '{vision_backend_selection}' initialization failed: {exc}")
|
||
print(f"[Text-to-Image] → Continuing with text expansion only (no vision captions)")
|
||
vision_llm = None
|
||
elif vision_backend_selection == "qwen3_vl":
|
||
# Use vision_api_endpoint for custom model path
|
||
vision_qwen_config = {
|
||
"model": None, # Auto-detect from vision_api_endpoint
|
||
"backend_hint": vision_api_endpoint if vision_api_endpoint != "http://localhost:1234/v1" else None,
|
||
}
|
||
vision_backend_mode = "qwen3_vl"
|
||
vision_caption_enabled = True
|
||
vision_model_used = "Qwen3-VL (auto-detected)"
|
||
else:
|
||
vision_backend_mode = "disabled"
|
||
|
||
if not vision_model_used:
|
||
vision_model_used = getattr(llm, "model_name", "auto-detected")
|
||
|
||
analysis_backend_label = vision_backend_mode
|
||
if analysis_backend_label == "inherit":
|
||
analysis_backend_label = f"inherit:{llm.backend_type}"
|
||
|
||
if not vision_caption_enabled:
|
||
vision_model_used = ""
|
||
|
||
reference_warnings: List[str] = []
|
||
if vision_init_warning:
|
||
reference_warnings.append(vision_init_warning)
|
||
if reference_images and not vision_caption_enabled:
|
||
reference_warnings.append(
|
||
"Vision captioning disabled; reference traits rely on heuristic estimates."
|
||
)
|
||
|
||
# Wrap vision processing in try/except to prevent vision failures from breaking text expansion
|
||
image_analyses = []
|
||
try:
|
||
for entry in reference_images:
|
||
label = entry.get("label", "Reference")
|
||
tensor = entry.get("tensor")
|
||
override_caption = entry.get("caption_override")
|
||
analysis = self._analyze_reference_image(
|
||
tensor,
|
||
label,
|
||
vision_llm=vision_llm if vision_caption_enabled and vision_llm is not None else None,
|
||
override_caption=override_caption,
|
||
qwen_config=vision_qwen_config if vision_backend_mode == "qwen3_vl" else None,
|
||
vision_temperature=temperature,
|
||
vision_backend=analysis_backend_label,
|
||
vision_model=vision_model_used
|
||
)
|
||
if override_caption:
|
||
existing_warnings = analysis.setdefault("warnings", [])
|
||
override_note = f"{label}: caption supplied by override."
|
||
if override_note not in existing_warnings:
|
||
existing_warnings.append(override_note)
|
||
warnings_from_analysis = analysis.get("warnings") or []
|
||
for warning in warnings_from_analysis:
|
||
if warning not in reference_warnings:
|
||
reference_warnings.append(warning)
|
||
image_analyses.append(analysis)
|
||
except Exception as vision_exc:
|
||
# Vision processing failed - log error but continue with text expansion
|
||
error_msg = f"Vision processing failed: {str(vision_exc)}"
|
||
print(f"[Text-to-Image] ⚠️ {error_msg}")
|
||
reference_warnings.append(error_msg)
|
||
# Create fallback analyses if we got partial results
|
||
if not image_analyses and reference_images:
|
||
for entry in reference_images:
|
||
label = entry.get("label", "Reference")
|
||
fallback = {
|
||
"label": label,
|
||
"summary": f"{label}: vision analysis failed, continuing with text expansion",
|
||
"details": ["Vision model unavailable"],
|
||
"warnings": [error_msg]
|
||
}
|
||
image_analyses.append(fallback)
|
||
|
||
# STEP 2: Resolve random/auto options
|
||
resolved_settings, setting_sources = self._resolve_settings(
|
||
camera_angle, composition, lighting_source, lighting_quality,
|
||
time_of_day, historical_period, weather, art_style, genre_style, color_mood,
|
||
creative_randomness,
|
||
subject_framing, subject_pose, prompt_context,
|
||
target_platform
|
||
)
|
||
|
||
# STEP 3: Build enhancement prompt
|
||
platform_config = get_platform_config(target_platform)
|
||
quality_emphasis = bool(platform_config.get("quality_emphasis", True))
|
||
resolved_settings["quality_emphasis"] = "enabled" if quality_emphasis else "disabled"
|
||
setting_sources["quality_emphasis"] = {"mode": "platform", "value": resolved_settings["quality_emphasis"]}
|
||
|
||
# Wrap reference guidance processing to prevent failures from blocking text expansion
|
||
reference_guidance = ""
|
||
reference_notes = []
|
||
reference_meta = {}
|
||
|
||
try:
|
||
directive_analyses, directive_meta = self._run_reference_directive_analysis(
|
||
image_analyses,
|
||
reference_plan,
|
||
llm
|
||
)
|
||
|
||
reference_guidance, reference_notes, guidance_meta = self._build_reference_guidance(
|
||
directive_analyses,
|
||
reference_plan,
|
||
llm,
|
||
prompt_context
|
||
)
|
||
reference_meta = self._merge_reference_metadata(reference_plan, directive_meta, guidance_meta)
|
||
except Exception as ref_exc:
|
||
# Reference guidance processing failed - log but continue
|
||
error_msg = f"Reference guidance generation failed: {str(ref_exc)}"
|
||
print(f"[Text-to-Image] ⚠️ {error_msg}")
|
||
reference_warnings.append(error_msg)
|
||
# Initialize empty reference data
|
||
reference_meta = {
|
||
"analysis_method": "failed",
|
||
"reference_count": len(image_analyses),
|
||
"llm_queries": 0,
|
||
"llm_successes": 0,
|
||
"warnings": [error_msg]
|
||
}
|
||
|
||
# Always update reference_meta with vision info (even if guidance failed)
|
||
reference_meta.update(
|
||
{
|
||
"vision_backend_requested": vision_backend_selection,
|
||
"vision_backend_resolved": vision_backend_mode,
|
||
"vision_caption_enabled": vision_caption_enabled,
|
||
"vision_model_used": vision_model_used,
|
||
}
|
||
)
|
||
if vision_qwen_config:
|
||
reference_meta["vision_backend_config"] = vision_qwen_config
|
||
if reference_warnings:
|
||
reference_meta["warnings"] = reference_warnings
|
||
|
||
backend_set = sorted(
|
||
{
|
||
str(analysis.get("vision_backend"))
|
||
for analysis in image_analyses
|
||
if analysis.get("vision_backend")
|
||
}
|
||
)
|
||
model_set = sorted(
|
||
{
|
||
str(analysis.get("vision_model"))
|
||
for analysis in image_analyses
|
||
if analysis.get("vision_model")
|
||
}
|
||
)
|
||
source_set = sorted(
|
||
{
|
||
str(analysis.get("vision_caption_source"))
|
||
for analysis in image_analyses
|
||
if analysis.get("vision_caption_source")
|
||
}
|
||
)
|
||
if backend_set:
|
||
reference_meta["vision_backends_used"] = backend_set
|
||
if model_set:
|
||
reference_meta["vision_models_used"] = model_set
|
||
if source_set:
|
||
reference_meta["vision_caption_sources"] = source_set
|
||
|
||
reference_caption_payload: List[Tuple[str, str]] = []
|
||
for analysis, entry in zip(image_analyses, reference_plan):
|
||
label = entry.get("label", analysis.get("label", "Reference"))
|
||
caption_text = analysis.get("vision_caption") or analysis.get("summary")
|
||
if caption_text:
|
||
prefix = "(override) " if analysis.get("caption_override") else ""
|
||
reference_caption_payload.append((label, f"{prefix}{caption_text}"))
|
||
|
||
creative_brainstorm = self._generate_creative_brainstorm(
|
||
text_prompt,
|
||
resolved_settings.get("creative_randomness", "off")
|
||
)
|
||
|
||
# genre_style = surreal is a procedure now, not the words "dreamlike, reality-bending": the surreal engine
|
||
# picks one operation for this idea and its block replaces the genre line. Nothing produced = old line.
|
||
self._surreal_block = ""
|
||
if str(resolved_settings.get("genre_style", "")).strip().lower() == "surreal":
|
||
try:
|
||
from .surreal_support import preset_block
|
||
self._surreal_block, surreal_note = preset_block(text_prompt, seed_value, "image")
|
||
print(f"[Text-to-Image] {surreal_note}")
|
||
except Exception as error:
|
||
print(f"[Text-to-Image] surreal engine unavailable ({error}); using the genre line")
|
||
|
||
system_prompt = self._build_system_prompt(
|
||
target_platform,
|
||
platform_config,
|
||
resolved_settings,
|
||
reference_plan,
|
||
prompt_context
|
||
)
|
||
|
||
user_prompt = self._build_user_prompt(
|
||
text_prompt,
|
||
resolved_settings,
|
||
target_platform,
|
||
prompt_context,
|
||
reference_guidance,
|
||
creative_brainstorm,
|
||
reference_caption_payload
|
||
)
|
||
|
||
# STEP 3b: Tell the LLM which elements were picked at random, so it keeps them
|
||
selected_elements = llm_block(wildcard_resolution, wildcard_llm_block)
|
||
if selected_elements:
|
||
user_prompt = f"{user_prompt}\n\n{selected_elements}"
|
||
|
||
# STEP 4: Prepare keyword overrides and call LLM
|
||
pos_kw_list = parse_keywords(positive_keywords)
|
||
neg_kw_list = parse_keywords(negative_keywords)
|
||
|
||
max_tokens_requested = self._determine_max_tokens(
|
||
platform_config,
|
||
resolved_settings.get("creative_randomness")
|
||
)
|
||
capped_tokens, token_cap_note = self._cap_tokens_for_backend(
|
||
llm.backend_type,
|
||
getattr(llm, "model_name", None),
|
||
max_tokens_requested
|
||
)
|
||
backend_params = {
|
||
"backend_type": llm_backend,
|
||
"endpoint": api_endpoint,
|
||
"temperature": temperature
|
||
}
|
||
|
||
response, llm_used, llm_attempts = self._call_main_llm_with_retries(
|
||
llm,
|
||
system_prompt,
|
||
user_prompt,
|
||
capped_tokens,
|
||
backend_params
|
||
)
|
||
llm = llm_used
|
||
raw_llm_output = response.get("response", "")
|
||
llm_error_message = response.get("error", "")
|
||
main_llm_success = bool(response.get("success")) and bool((raw_llm_output or "").strip())
|
||
|
||
fallback_meta: Optional[Dict[str, Any]] = None
|
||
fallback_used = False
|
||
|
||
# STEP 5: Parse and format response (fallback on failure)
|
||
if main_llm_success:
|
||
enhanced_prompt = self._parse_llm_response(raw_llm_output, target_platform)
|
||
print(f"[Text-to-Image] LLM successfully enhanced prompt (length: {len(enhanced_prompt)} chars)")
|
||
else:
|
||
enhanced_prompt, fallback_meta = self._build_deterministic_fallback_prompt(
|
||
text_prompt,
|
||
target_platform,
|
||
platform_config,
|
||
resolved_settings,
|
||
reference_notes
|
||
)
|
||
fallback_used = True
|
||
print(f"[Text-to-Image] ⚠️ LLM failed or returned empty content - using deterministic fallback")
|
||
if llm_error_message:
|
||
print(f"[Text-to-Image] Error details: {llm_error_message}")
|
||
|
||
# STEP 5.5: Restore emphasis syntax that was protected
|
||
enhanced_prompt = self._restore_emphasis_syntax(enhanced_prompt)
|
||
|
||
# STEP 6: Add custom keywords
|
||
if pos_kw_list:
|
||
enhanced_prompt = self._add_keywords(enhanced_prompt, pos_kw_list, target_platform)
|
||
|
||
# STEP 6b: Verbatim wildcard picks (__=name__) must survive the rewrite word for word
|
||
enhanced_prompt, _restored_verbatim = enforce_verbatim(enhanced_prompt, wildcard_resolution)
|
||
|
||
# STEP 7: Add platform-specific required tokens
|
||
enhanced_prompt = self._add_platform_requirements(enhanced_prompt, target_platform)
|
||
|
||
# STEP 7.5: Boost density if the output is too terse
|
||
enhanced_prompt, density_meta = self._ensure_prompt_density(
|
||
enhanced_prompt,
|
||
target_platform,
|
||
platform_config,
|
||
reference_notes,
|
||
resolved_settings
|
||
)
|
||
|
||
if density_meta and density_meta.get("added_phrases"):
|
||
print(
|
||
f"[Text-to-Image] ℹ️ Density boost added {density_meta['added_phrases']} phrase(s) "
|
||
f"(words {density_meta['before_words']}→{density_meta['after_words']})."
|
||
)
|
||
else:
|
||
density_meta = None
|
||
|
||
# STEP 8: Generate negative prompt
|
||
negative_prompt = get_negative_prompt_for_platform(target_platform, neg_kw_list)
|
||
|
||
# STEP 8.5: Determine actual selections mentioned by the LLM output
|
||
actual_selections = self._infer_setting_mentions(enhanced_prompt)
|
||
display_context = {
|
||
"setting_sources": setting_sources,
|
||
"actual_selections": actual_selections,
|
||
"reference_meta": reference_meta,
|
||
"reference_image_count": len(reference_images),
|
||
"main_llm_success": main_llm_success,
|
||
"main_llm_error": llm_error_message,
|
||
"llm_attempts": llm_attempts,
|
||
"llm_token_cap_note": token_cap_note,
|
||
"max_tokens_requested": max_tokens_requested,
|
||
"max_tokens_used": capped_tokens,
|
||
"fallback_used": fallback_used,
|
||
"fallback_meta": fallback_meta,
|
||
"density_meta": density_meta,
|
||
"quality_emphasis": quality_emphasis,
|
||
"reference_guidance_used": bool(reference_guidance.strip()) if reference_guidance else False,
|
||
"reference_directives": reference_plan,
|
||
"reference_warnings": reference_warnings,
|
||
"random_seed": {
|
||
"value": seed_value,
|
||
"mode": resolved_seed_mode,
|
||
"requested": requested_seed,
|
||
"requested_mode": seed_mode_normalized
|
||
},
|
||
"vision": {
|
||
"requested_backend": vision_backend_selection,
|
||
"resolved_backend": vision_backend_mode,
|
||
"resolved_model": vision_model_used,
|
||
"caption_enabled": vision_caption_enabled,
|
||
}
|
||
}
|
||
|
||
# STEP 9: Format settings display
|
||
settings_display = self._format_settings_display(
|
||
resolved_settings,
|
||
platform_config,
|
||
reference_notes,
|
||
display_context
|
||
)
|
||
wildcard_report = report_lines(wildcard_resolution)
|
||
if wildcard_report:
|
||
settings_display = f"{settings_display}\n\n{wildcard_report}"
|
||
|
||
# STEP 10: Save if requested
|
||
if save_to_file:
|
||
metadata = {
|
||
"type": "text-to-image",
|
||
"platform": target_platform,
|
||
"platform_name": platform_config["name"],
|
||
"model": llm.model_name or "auto-detected",
|
||
"backend": llm_backend,
|
||
"temperature": temperature,
|
||
"original_prompt": text_prompt,
|
||
"settings": resolved_settings,
|
||
"setting_sources": setting_sources,
|
||
"reference_meta": reference_meta,
|
||
"vision_captions": reference_meta.get("vision_captions", []),
|
||
"vision_caption_errors": reference_meta.get("vision_caption_errors", []),
|
||
"vision_backend_requested": vision_backend_selection,
|
||
"vision_backend_resolved": vision_backend_mode,
|
||
"vision_model_used": vision_model_used,
|
||
"vision_caption_enabled": vision_caption_enabled,
|
||
"system_prompt": system_prompt,
|
||
"user_prompt": user_prompt,
|
||
"raw_llm_output": raw_llm_output,
|
||
"creative_brainstorm": creative_brainstorm,
|
||
"reference_guidance": reference_guidance,
|
||
"reference_notes_log": reference_notes,
|
||
"reference_llm_logs": reference_meta.get("llm_logs", []),
|
||
"main_llm_success": main_llm_success,
|
||
"main_llm_error": llm_error_message,
|
||
"reference_warnings": reference_warnings,
|
||
"max_tokens_requested": max_tokens_requested,
|
||
"max_tokens_used": capped_tokens,
|
||
"token_cap_note": token_cap_note,
|
||
"llm_attempts": llm_attempts,
|
||
"fallback_used": fallback_used,
|
||
"fallback_meta": fallback_meta,
|
||
"density_meta": density_meta,
|
||
"random_seed_requested": requested_seed,
|
||
"random_seed_used": seed_value,
|
||
"random_seed_mode_requested": seed_mode_normalized,
|
||
"random_seed_mode_resolved": resolved_seed_mode
|
||
}
|
||
|
||
if vision_qwen_config:
|
||
metadata["vision_backend_config"] = vision_qwen_config
|
||
|
||
save_result = save_prompts_to_file(
|
||
positive_prompt=enhanced_prompt,
|
||
negative_prompt=negative_prompt,
|
||
breakdown=settings_display,
|
||
metadata=metadata,
|
||
filename_base=filename_base,
|
||
output_dir=self.output_dir
|
||
)
|
||
|
||
file_status = f"💾 Saved to {save_result['filepath']}" if save_result["success"] else "⚠️ Save failed"
|
||
else:
|
||
file_status = "Not saved"
|
||
|
||
llm_status_parts = []
|
||
if main_llm_success:
|
||
llm_status_parts.append("Main LLM: responded")
|
||
else:
|
||
error_snippet = (llm_error_message or "unknown error").splitlines()[0][:120]
|
||
error_snippet = error_snippet.replace('|', '/')
|
||
llm_status_parts.append(f"Main LLM: failed ({error_snippet})")
|
||
|
||
if token_cap_note:
|
||
llm_status_parts.append(
|
||
f"Token cap {capped_tokens}/{max_tokens_requested}"
|
||
)
|
||
|
||
if llm_attempts and len(llm_attempts) > 1:
|
||
llm_status_parts.append(f"LLM attempts: {len(llm_attempts)}")
|
||
|
||
if fallback_used:
|
||
fallback_phrase_count = fallback_meta.get("combined_total") if fallback_meta else None
|
||
if fallback_phrase_count:
|
||
llm_status_parts.append(f"Fallback detail +{fallback_phrase_count}")
|
||
else:
|
||
llm_status_parts.append("Fallback detail applied")
|
||
|
||
if density_meta:
|
||
llm_status_parts.append(
|
||
f"Density boost +{density_meta.get('added_phrases', 0)}"
|
||
)
|
||
|
||
ref_count = reference_meta.get("reference_count", 0)
|
||
ref_method = reference_meta.get("analysis_method", "none")
|
||
if ref_count:
|
||
queries = reference_meta.get("llm_queries", 0)
|
||
successes = reference_meta.get("llm_successes", 0)
|
||
if queries:
|
||
llm_status_parts.append(
|
||
f"Reference LLM: {successes}/{queries} responses ({ref_method})"
|
||
)
|
||
else:
|
||
llm_status_parts.append(f"Reference LLM: not invoked ({ref_method})")
|
||
else:
|
||
llm_status_parts.append("Reference LLM: no references")
|
||
|
||
if ref_count:
|
||
llm_status_parts.append(f"References sent: {ref_count}")
|
||
directive_labels = reference_meta.get("directive_labels") or []
|
||
if directive_labels:
|
||
llm_status_parts.append("Directives: " + ", ".join(directive_labels))
|
||
else:
|
||
llm_status_parts.append("References sent: none")
|
||
|
||
if reference_warnings:
|
||
warning_preview = reference_warnings[0]
|
||
if len(reference_warnings) > 1:
|
||
warning_preview += f" (+{len(reference_warnings) - 1} more)"
|
||
if len(warning_preview) > 160:
|
||
warning_preview = warning_preview[:157] + "…"
|
||
llm_status_parts.append(f"Reference warnings: {warning_preview}")
|
||
|
||
llm_status_parts.append(f"Seed: {seed_value} ({resolved_seed_mode})")
|
||
|
||
wildcard_slot_count = len(wildcard_resolution.top_level_slots())
|
||
if wildcard_slot_count:
|
||
llm_status_parts.append(f"Wildcards: {wildcard_slot_count}")
|
||
if wildcard_resolution.warnings:
|
||
llm_status_parts.append(f"Wildcard warnings: {len(wildcard_resolution.warnings)} (see settings_used)")
|
||
|
||
status_prefix = "✅" if main_llm_success else "⚠️"
|
||
status = (
|
||
f"{status_prefix} Enhanced for {platform_config['name']} | "
|
||
+ " | ".join(llm_status_parts)
|
||
+ f" | {file_status}"
|
||
)
|
||
|
||
# Format vision captions for output
|
||
vision_captions_list = reference_meta.get("vision_captions", [])
|
||
if vision_captions_list:
|
||
vision_caption_output = "\n\n".join(
|
||
f"Reference {i+1}: {caption}"
|
||
for i, caption in enumerate(vision_captions_list)
|
||
)
|
||
else:
|
||
vision_caption_output = ""
|
||
|
||
return (
|
||
enhanced_prompt,
|
||
negative_prompt,
|
||
settings_display,
|
||
status,
|
||
vision_caption_output,
|
||
seed_value,
|
||
{
|
||
"ui": {
|
||
"random_seed": int(seed_value),
|
||
"seed_mode": resolved_seed_mode
|
||
}
|
||
}
|
||
)
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
error_detail = traceback.format_exc()
|
||
print(f"ERROR in text-to-image enhancement: {error_detail}")
|
||
return (
|
||
text_prompt,
|
||
"",
|
||
"",
|
||
f"❌ Error: {str(e)}",
|
||
"",
|
||
-1
|
||
)
|
||
finally:
|
||
random.setstate(python_random_state)
|
||
np.random.set_state(numpy_random_state)
|
||
|
||
def _get_simple_image_description(self, image: torch.Tensor, label: str) -> str:
|
||
"""Backward-compatible helper returning the concise summary of an image analysis."""
|
||
|
||
analysis = self._analyze_reference_image(image, label)
|
||
return str(analysis.get("summary", f"{label}: [Image provided as reference]"))
|
||
|
||
def _process_alternations(self, text: str) -> str:
|
||
"""
|
||
Process alternation syntax {option1|option2|option3}
|
||
Replaces with randomly chosen option
|
||
"""
|
||
import re
|
||
|
||
# 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
|
||
"""
|
||
import re
|
||
|
||
# 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
|
||
|
||
def _resolve_settings(
|
||
self,
|
||
camera_angle: str,
|
||
composition: str,
|
||
lighting_source: str,
|
||
lighting_quality: str,
|
||
time_of_day: str,
|
||
historical_period: str,
|
||
weather: str,
|
||
art_style: str,
|
||
genre_style: str,
|
||
color_mood: str,
|
||
creative_randomness: str,
|
||
subject_framing: str,
|
||
subject_pose: str,
|
||
prompt_context: str,
|
||
platform: str
|
||
) -> Tuple[Dict[str, str], Dict[str, Dict[str, str]]]:
|
||
"""Resolve auto/random options to actual values and record their origins."""
|
||
|
||
resolved: Dict[str, str] = {}
|
||
sources: Dict[str, Dict[str, str]] = {}
|
||
|
||
# Camera angle
|
||
if camera_angle == "random":
|
||
choice = random.choice(self.camera_angles)
|
||
resolved["camera_angle"] = choice
|
||
sources["camera_angle"] = {"mode": "random", "value": choice}
|
||
elif camera_angle not in ["auto", "none"]:
|
||
resolved["camera_angle"] = camera_angle
|
||
sources["camera_angle"] = {"mode": "user", "value": camera_angle}
|
||
elif camera_angle == "auto":
|
||
resolved["camera_angle"] = "auto (LLM chooses camera angle)"
|
||
sources["camera_angle"] = {"mode": "llm"}
|
||
else:
|
||
sources["camera_angle"] = {"mode": "none"}
|
||
|
||
# Composition
|
||
if composition == "random":
|
||
choice = random.choice(self.composition_styles)
|
||
resolved["composition"] = choice
|
||
sources["composition"] = {"mode": "random", "value": choice}
|
||
elif composition not in ["auto", "none"]:
|
||
resolved["composition"] = composition
|
||
sources["composition"] = {"mode": "user", "value": composition}
|
||
elif composition == "auto":
|
||
resolved["composition"] = "auto (LLM arranges composition)"
|
||
sources["composition"] = {"mode": "llm"}
|
||
else:
|
||
sources["composition"] = {"mode": "none"}
|
||
|
||
# Lighting source
|
||
if lighting_source == "random":
|
||
choice = random.choice(self.lighting_sources)
|
||
resolved["lighting_source"] = choice
|
||
sources["lighting_source"] = {"mode": "random", "value": choice}
|
||
elif lighting_source not in ["auto", "none"]:
|
||
resolved["lighting_source"] = lighting_source
|
||
sources["lighting_source"] = {"mode": "user", "value": lighting_source}
|
||
elif lighting_source == "auto":
|
||
resolved["lighting_source"] = "auto (LLM selects lighting source)"
|
||
sources["lighting_source"] = {"mode": "llm"}
|
||
else:
|
||
sources["lighting_source"] = {"mode": "none"}
|
||
|
||
# Lighting quality
|
||
if lighting_quality == "random":
|
||
choice = random.choice(self.lighting_quality)
|
||
resolved["lighting_quality"] = choice
|
||
sources["lighting_quality"] = {"mode": "random", "value": choice}
|
||
elif lighting_quality not in ["auto", "none"]:
|
||
resolved["lighting_quality"] = lighting_quality
|
||
sources["lighting_quality"] = {"mode": "user", "value": lighting_quality}
|
||
elif lighting_quality == "auto":
|
||
resolved["lighting_quality"] = "auto (LLM refines lighting quality)"
|
||
sources["lighting_quality"] = {"mode": "llm"}
|
||
else:
|
||
sources["lighting_quality"] = {"mode": "none"}
|
||
|
||
# Time of day
|
||
if time_of_day == "random":
|
||
choice = random.choice(self.times_of_day)
|
||
resolved["time_of_day"] = choice
|
||
sources["time_of_day"] = {"mode": "random", "value": choice}
|
||
elif time_of_day not in ["auto", "none"]:
|
||
resolved["time_of_day"] = time_of_day
|
||
sources["time_of_day"] = {"mode": "user", "value": time_of_day}
|
||
elif time_of_day == "auto":
|
||
resolved["time_of_day"] = "auto (LLM selects time of day)"
|
||
sources["time_of_day"] = {"mode": "llm"}
|
||
else:
|
||
sources["time_of_day"] = {"mode": "none"}
|
||
|
||
# Historical period
|
||
if historical_period == "random":
|
||
choice = random.choice(self.historical_periods)
|
||
resolved["historical_period"] = choice
|
||
sources["historical_period"] = {"mode": "random", "value": choice}
|
||
elif historical_period not in ["auto", "none"]:
|
||
resolved["historical_period"] = historical_period
|
||
sources["historical_period"] = {"mode": "user", "value": historical_period}
|
||
elif historical_period == "auto":
|
||
resolved["historical_period"] = "auto (LLM infers historical context)"
|
||
sources["historical_period"] = {"mode": "llm"}
|
||
else:
|
||
sources["historical_period"] = {"mode": "none"}
|
||
|
||
# Weather
|
||
if weather == "random":
|
||
choice = random.choice(self.weather_conditions)
|
||
resolved["weather"] = choice
|
||
sources["weather"] = {"mode": "random", "value": choice}
|
||
elif weather not in ["auto", "none"]:
|
||
resolved["weather"] = weather
|
||
sources["weather"] = {"mode": "user", "value": weather}
|
||
elif weather == "auto":
|
||
resolved["weather"] = "auto (LLM selects weather)"
|
||
sources["weather"] = {"mode": "llm"}
|
||
else:
|
||
sources["weather"] = {"mode": "none"}
|
||
|
||
# Art style
|
||
if art_style not in ["auto", "none"]:
|
||
resolved["art_style"] = art_style
|
||
sources["art_style"] = {"mode": "user", "value": art_style}
|
||
elif art_style == "auto":
|
||
resolved["art_style"] = "auto (LLM selects art style)"
|
||
sources["art_style"] = {"mode": "llm"}
|
||
else:
|
||
sources["art_style"] = {"mode": "none"}
|
||
|
||
# Color mood
|
||
if color_mood == "random":
|
||
choice = random.choice([
|
||
"vibrant", "muted", "warm tones", "cool tones", "pastel", "high contrast"
|
||
])
|
||
resolved["color_mood"] = choice
|
||
sources["color_mood"] = {"mode": "random", "value": choice}
|
||
elif color_mood not in ["auto", "none"]:
|
||
resolved["color_mood"] = color_mood
|
||
sources["color_mood"] = {"mode": "user", "value": color_mood}
|
||
elif color_mood == "auto":
|
||
resolved["color_mood"] = "auto (LLM selects color mood)"
|
||
sources["color_mood"] = {"mode": "llm"}
|
||
else:
|
||
sources["color_mood"] = {"mode": "none"}
|
||
|
||
# Genre style
|
||
if genre_style == "random":
|
||
choice = random.choice(self.genre_styles)
|
||
resolved["genre_style"] = choice
|
||
sources["genre_style"] = {"mode": "random", "value": choice}
|
||
elif genre_style not in ["auto", "none"]:
|
||
resolved["genre_style"] = genre_style
|
||
sources["genre_style"] = {"mode": "user", "value": genre_style}
|
||
elif genre_style == "auto":
|
||
resolved["genre_style"] = "auto (LLM selects genre/mood)"
|
||
sources["genre_style"] = {"mode": "llm"}
|
||
else:
|
||
sources["genre_style"] = {"mode": "none"}
|
||
|
||
# Creative randomness level
|
||
if creative_randomness == "auto":
|
||
resolved_randomness = self._auto_randomness_choice(platform)
|
||
sources["creative_randomness"] = {"mode": "auto", "value": resolved_randomness}
|
||
elif creative_randomness == "none":
|
||
resolved_randomness = "none"
|
||
sources["creative_randomness"] = {"mode": "none", "value": "none"}
|
||
else:
|
||
resolved_randomness = creative_randomness
|
||
sources["creative_randomness"] = {"mode": "user", "value": resolved_randomness}
|
||
resolved["creative_randomness"] = resolved_randomness
|
||
|
||
# Subject framing
|
||
if subject_framing == "random":
|
||
choice = random.choice(self.subject_framings)
|
||
resolved["subject_framing"] = choice
|
||
sources["subject_framing"] = {"mode": "random", "value": choice}
|
||
elif subject_framing not in ["auto", "none"]:
|
||
resolved["subject_framing"] = subject_framing
|
||
sources["subject_framing"] = {"mode": "user", "value": subject_framing}
|
||
elif subject_framing == "auto":
|
||
resolved["subject_framing"] = "auto (LLM frames subject)"
|
||
sources["subject_framing"] = {"mode": "llm"}
|
||
else:
|
||
sources["subject_framing"] = {"mode": "none"}
|
||
|
||
# Subject pose
|
||
if subject_pose == "random":
|
||
choice = random.choice(self.subject_poses)
|
||
resolved["subject_pose"] = choice
|
||
sources["subject_pose"] = {"mode": "random", "value": choice}
|
||
elif subject_pose not in ["auto", "none"]:
|
||
resolved["subject_pose"] = subject_pose
|
||
sources["subject_pose"] = {"mode": "user", "value": subject_pose}
|
||
elif subject_pose == "auto":
|
||
resolved["subject_pose"] = "auto (LLM chooses pose)"
|
||
sources["subject_pose"] = {"mode": "llm"}
|
||
else:
|
||
sources["subject_pose"] = {"mode": "none"}
|
||
|
||
# Prompt context
|
||
if prompt_context == "auto":
|
||
resolved["prompt_context"] = "auto (LLM interprets input role)"
|
||
sources["prompt_context"] = {"mode": "llm"}
|
||
elif prompt_context == "none":
|
||
resolved["prompt_context"] = "none"
|
||
sources["prompt_context"] = {"mode": "none"}
|
||
else:
|
||
resolved["prompt_context"] = prompt_context
|
||
sources["prompt_context"] = {"mode": "user", "value": prompt_context}
|
||
|
||
# Attach platform-specific defaults for length/detail expectations
|
||
platform_config = get_platform_config(platform)
|
||
resolved_length = platform_config.get(
|
||
"length_guidance",
|
||
"Generate a full-length, highly detailed prompt"
|
||
)
|
||
resolved_detail = platform_config.get(
|
||
"detail_expectation",
|
||
"Ultra detailed description"
|
||
)
|
||
resolved["length_mode"] = resolved_length
|
||
resolved["detail_mode"] = resolved_detail
|
||
sources["length_mode"] = {"mode": "platform", "value": resolved_length}
|
||
sources["detail_mode"] = {"mode": "platform", "value": resolved_detail}
|
||
|
||
return resolved, sources
|
||
|
||
def _auto_randomness_choice(self, platform: str) -> str:
|
||
"""Choose a creative randomness default based on platform capabilities."""
|
||
|
||
platform = platform.lower()
|
||
if platform in {"flux", "chroma"}:
|
||
return "bold"
|
||
if platform in {"wan_image", "wan22", "wan"}:
|
||
return "moderate"
|
||
if platform in {"qwen_image", "qwen_image_edit"}:
|
||
return "storyteller"
|
||
if platform in {"hunyuan_image"}:
|
||
return "subtle"
|
||
if platform in {"sd_xl", "pony", "illustrious"}:
|
||
return "moderate"
|
||
return "subtle"
|
||
|
||
def _analyze_reference_image(
|
||
self,
|
||
image: Optional[torch.Tensor],
|
||
label: str,
|
||
vision_llm: Optional[LLMBackend] = None,
|
||
override_caption: Optional[str] = None,
|
||
qwen_config: Optional[Dict[str, Any]] = None,
|
||
vision_temperature: float = 0.7,
|
||
vision_backend: str = "",
|
||
vision_model: str = ""
|
||
) -> Dict[str, Any]:
|
||
"""Generate descriptive statistics and optional vision captions for a reference image."""
|
||
|
||
llm_logs: List[Dict[str, Any]] = []
|
||
warnings: List[str] = []
|
||
vision_caption: Optional[str] = None
|
||
vision_error: Optional[str] = None
|
||
vision_caption_source: Optional[str] = None
|
||
|
||
try:
|
||
if isinstance(image, torch.Tensor):
|
||
img_np = image.detach().cpu().numpy()
|
||
|
||
if img_np.ndim == 4:
|
||
img_np = img_np[0]
|
||
|
||
img_np = np.clip(img_np, 0.0, 1.0)
|
||
|
||
if img_np.shape[-1] < 3:
|
||
rgb = np.repeat(img_np[..., :1], 3, axis=-1)
|
||
else:
|
||
rgb = img_np[..., :3]
|
||
|
||
height, width = rgb.shape[:2]
|
||
aspect_ratio = width / height if height else 1.0
|
||
orientation = "landscape" if aspect_ratio > 1.25 else "portrait" if aspect_ratio < 0.8 else "square"
|
||
|
||
avg_rgb = rgb.mean(axis=(0, 1))
|
||
brightness = float(avg_rgb.mean())
|
||
|
||
if brightness > 0.7:
|
||
tone = "bright"
|
||
elif brightness > 0.45:
|
||
tone = "balanced"
|
||
else:
|
||
tone = "dim"
|
||
|
||
grayscale = rgb.mean(axis=2)
|
||
contrast_value = float(grayscale.std())
|
||
if contrast_value < 0.08:
|
||
contrast_desc = "low contrast"
|
||
elif contrast_value < 0.16:
|
||
contrast_desc = "moderate contrast"
|
||
else:
|
||
contrast_desc = "high contrast"
|
||
|
||
edge_y = np.abs(np.diff(grayscale, axis=0))
|
||
edge_x = np.abs(np.diff(grayscale, axis=1))
|
||
edge_density = float((edge_x.mean() + edge_y.mean()) / 2.0)
|
||
if edge_density < 0.02:
|
||
texture_desc = "soft textures"
|
||
elif edge_density < 0.05:
|
||
texture_desc = "mixed textures"
|
||
else:
|
||
texture_desc = "detailed textures"
|
||
|
||
palette_names, palette_hex = self._extract_palette(rgb)
|
||
|
||
warmth_score = float(avg_rgb[0] - avg_rgb[2])
|
||
if warmth_score > 0.05:
|
||
temperature_desc = "warm color temperature"
|
||
elif warmth_score < -0.05:
|
||
temperature_desc = "cool color temperature"
|
||
else:
|
||
temperature_desc = "neutral color temperature"
|
||
|
||
genre_hint = self._infer_genre_suggestion(tone, contrast_desc, palette_names)
|
||
|
||
palette_description = ', '.join(palette_names) if palette_names else "mixed tones"
|
||
base_summary = (
|
||
f"{label}: {orientation} framing with {tone} exposure, {contrast_desc}, {temperature_desc}, "
|
||
f"palette leans {palette_description}"
|
||
)
|
||
|
||
detail_lines = [
|
||
f"Orientation: {orientation} framing that feels {texture_desc}.",
|
||
f"Lighting: {tone} exposure with {temperature_desc}.",
|
||
f"Contrast impression: {contrast_desc} supporting {texture_desc} detail.",
|
||
f"Palette impression: {palette_description} hues dominate.",
|
||
f"Suggested genre alignment: {genre_hint}"
|
||
]
|
||
|
||
category_notes = {
|
||
"caption": base_summary,
|
||
"style": f"{contrast_desc} with {texture_desc}; keep the {temperature_desc} mood.",
|
||
"lighting": f"{tone} lighting with {temperature_desc}; preserve the same luminance mood.",
|
||
"genre": genre_hint,
|
||
"time_period": "No explicit era cues detected; align with selected time period.",
|
||
"subject": f"Maintain the subject presence implied by the {orientation} framing.",
|
||
"objects": "Refer to this image for relative placement of supporting forms.",
|
||
"color": f"Palette guidance: {palette_description} tones.",
|
||
"composition": f"Keep a {orientation} layout and similar balance between subject and environment."
|
||
}
|
||
|
||
caption_prompt = (
|
||
"Describe this reference image in exhaustive detail. Cover subjects, setting, focal points, "
|
||
"lighting, mood, palette, and any noteworthy props or actions. Write at least three complete sentences "
|
||
"that mention every important element you observe."
|
||
)
|
||
caption_system_prompt = (
|
||
"You are an expert visual analyst. Describe every element in the image clearly and precisely."
|
||
)
|
||
|
||
pil_image: Optional[Image.Image] = None
|
||
|
||
if override_caption and override_caption.strip():
|
||
vision_caption = override_caption.strip()
|
||
vision_caption_source = "override"
|
||
elif qwen_config:
|
||
try:
|
||
pil_image = Image.fromarray((rgb * 255).astype(np.uint8))
|
||
qwen_result = caption_with_qwen3_vl(
|
||
image=pil_image,
|
||
prompt=caption_prompt,
|
||
system_prompt=caption_system_prompt,
|
||
model_spec=qwen_config.get("model"),
|
||
backend_hint=qwen_config.get("backend_hint"),
|
||
max_new_tokens=768,
|
||
temperature=max(0.0, float(vision_temperature)),
|
||
)
|
||
if qwen_result.get("success") and qwen_result.get("caption"):
|
||
vision_caption = qwen_result["caption"].strip()
|
||
vision_caption_source = "qwen3_vl"
|
||
else:
|
||
error_message = qwen_result.get("error") or "Qwen3-VL caption failed"
|
||
snippet = str(error_message).splitlines()[0][:200]
|
||
warnings.append(f"{label}: Qwen3-VL caption failed ({snippet})")
|
||
vision_error = snippet
|
||
except Exception as exc:
|
||
snippet = str(exc).splitlines()[0][:200]
|
||
warnings.append(f"{label}: Qwen3-VL caption failed ({snippet})")
|
||
vision_error = snippet
|
||
elif vision_llm:
|
||
if pil_image is None:
|
||
pil_image = Image.fromarray((rgb * 255).astype(np.uint8))
|
||
image_buffer = io.BytesIO()
|
||
pil_image.save(image_buffer, format="PNG")
|
||
image_bytes = image_buffer.getvalue()
|
||
image_buffer.close()
|
||
|
||
caption_result = vision_llm.caption_image(
|
||
image_bytes=image_bytes,
|
||
label=label,
|
||
prompt=caption_prompt,
|
||
max_tokens=480
|
||
)
|
||
log_entry = caption_result.get("log_entry") if isinstance(caption_result, dict) else None
|
||
if log_entry:
|
||
llm_logs.append(log_entry)
|
||
if caption_result.get("success") and caption_result.get("caption"):
|
||
vision_caption = caption_result["caption"].strip()
|
||
vision_caption_source = vision_llm.backend_type
|
||
else:
|
||
error_message = caption_result.get("error") if isinstance(caption_result, dict) else None
|
||
if error_message:
|
||
snippet = str(error_message).splitlines()[0][:160]
|
||
warnings.append(f"{label}: vision caption failed ({snippet})")
|
||
vision_error = snippet
|
||
else:
|
||
if not (override_caption and override_caption.strip()):
|
||
raise ValueError("Reference image must be provided unless a caption override is supplied")
|
||
|
||
vision_caption = override_caption.strip()
|
||
vision_caption_source = "override"
|
||
warnings.append(f"{label}: analysis based solely on supplied caption override.")
|
||
height = width = 0
|
||
aspect_ratio = 1.0
|
||
orientation = "unspecified"
|
||
tone = "balanced"
|
||
contrast_desc = "moderate contrast"
|
||
texture_desc = "mixed textures"
|
||
temperature_desc = "neutral color temperature"
|
||
palette_names = []
|
||
palette_hex = []
|
||
palette_description = "mixed tones"
|
||
genre_hint = "Flexible genre potential; adapt to project goals."
|
||
base_summary = f"{label}: reference caption provided without image analysis."
|
||
detail_lines = [vision_caption]
|
||
category_notes = {
|
||
key: vision_caption for key in self.reference_usage_labels.keys()
|
||
}
|
||
|
||
summary = vision_caption and f"{label}: {vision_caption}" or base_summary
|
||
|
||
if vision_caption:
|
||
detail_lines = [vision_caption] + detail_lines
|
||
caption_for_notes = vision_caption
|
||
category_notes = {
|
||
key: caption_for_notes for key in self.reference_usage_labels.keys()
|
||
}
|
||
category_notes["lighting"] = (
|
||
f"{caption_for_notes} | Lighting heuristics: {tone} with {temperature_desc}."
|
||
)
|
||
category_notes["composition"] = (
|
||
f"{caption_for_notes} | Maintain the {orientation} framing relationships."
|
||
)
|
||
category_notes["color"] = (
|
||
f"{caption_for_notes} | Palette impression: {palette_description} hues."
|
||
)
|
||
|
||
analysis_result: Dict[str, Any] = {
|
||
"label": label,
|
||
"width": width,
|
||
"height": height,
|
||
"orientation": orientation,
|
||
"aspect_ratio": aspect_ratio,
|
||
"tone": tone,
|
||
"contrast_description": contrast_desc,
|
||
"texture_description": texture_desc,
|
||
"temperature_description": temperature_desc,
|
||
"palette_names": palette_names,
|
||
"palette_hex": palette_hex,
|
||
"summary": summary,
|
||
"details": detail_lines,
|
||
"category_notes": category_notes
|
||
}
|
||
|
||
if llm_logs:
|
||
analysis_result["llm_logs"] = llm_logs
|
||
if warnings:
|
||
analysis_result["warnings"] = warnings
|
||
if vision_caption:
|
||
analysis_result["vision_caption"] = vision_caption
|
||
if override_caption and override_caption.strip():
|
||
analysis_result["caption_override"] = True
|
||
if vision_caption_source:
|
||
analysis_result["vision_caption_source"] = vision_caption_source
|
||
if vision_error:
|
||
analysis_result["vision_caption_error"] = vision_error
|
||
analysis_result["vision_backend"] = vision_backend or "disabled"
|
||
analysis_result["vision_model"] = vision_model or ""
|
||
|
||
return analysis_result
|
||
except Exception as exc:
|
||
print(f"Warning: Could not analyze reference image ({label}): {exc}")
|
||
fallback_summary = f"{label}: reference image provided (analysis unavailable)"
|
||
result = {
|
||
"label": label,
|
||
"summary": fallback_summary,
|
||
"details": [fallback_summary],
|
||
"category_notes": {
|
||
key: fallback_summary for key in self.reference_usage_labels.keys()
|
||
},
|
||
"palette_names": [],
|
||
"palette_hex": []
|
||
}
|
||
if warnings:
|
||
result["warnings"] = warnings
|
||
if llm_logs:
|
||
result["llm_logs"] = llm_logs
|
||
return result
|
||
|
||
def _extract_palette(self, rgb: np.ndarray, max_colors: int = 3) -> Tuple[List[str], List[str]]:
|
||
"""Approximate dominant palette using simple quantization."""
|
||
|
||
flat = rgb.reshape(-1, 3)
|
||
if flat.shape[0] > 5000:
|
||
indices = np.random.choice(flat.shape[0], 5000, replace=False)
|
||
flat = flat[indices]
|
||
|
||
quantized = (flat * 255).astype(int)
|
||
quantized = (quantized // 32) * 32 + 16
|
||
|
||
color_counts: Dict[Tuple[int, int, int], int] = {}
|
||
for r, g, b in quantized:
|
||
key = (int(r), int(g), int(b))
|
||
color_counts[key] = color_counts.get(key, 0) + 1
|
||
|
||
if not color_counts:
|
||
color_counts = {(128, 128, 128): 1}
|
||
|
||
top_colors = sorted(color_counts.items(), key=lambda item: item[1], reverse=True)[:max_colors]
|
||
|
||
palette_rgb = [np.array(color) for color, _ in top_colors]
|
||
palette_names = [self._nearest_color_name(tuple(color)) for color in palette_rgb]
|
||
palette_hex = [self._rgb_to_hex(tuple(color)) for color in palette_rgb]
|
||
|
||
return palette_names, palette_hex
|
||
|
||
def _nearest_color_name(self, rgb: Tuple[int, int, int]) -> str:
|
||
"""Map RGB values to a basic color name."""
|
||
|
||
reference_colors = {
|
||
"black": (0, 0, 0),
|
||
"white": (255, 255, 255),
|
||
"gray": (128, 128, 128),
|
||
"red": (210, 50, 40),
|
||
"orange": (230, 140, 45),
|
||
"yellow": (235, 220, 70),
|
||
"green": (70, 150, 70),
|
||
"teal": (50, 150, 150),
|
||
"blue": (60, 100, 220),
|
||
"purple": (130, 80, 200),
|
||
"magenta": (200, 70, 180),
|
||
"brown": (120, 80, 50),
|
||
"pink": (240, 170, 200)
|
||
}
|
||
|
||
r, g, b = rgb
|
||
best_name = "color"
|
||
best_distance = float("inf")
|
||
for name, ref_rgb in reference_colors.items():
|
||
distance = ((r - ref_rgb[0]) ** 2 + (g - ref_rgb[1]) ** 2 + (b - ref_rgb[2]) ** 2) ** 0.5
|
||
if distance < best_distance:
|
||
best_distance = distance
|
||
best_name = name
|
||
return best_name
|
||
|
||
def _rgb_to_hex(self, rgb: Tuple[int, int, int]) -> str:
|
||
return "#" + "".join(f"{max(0, min(255, channel)):02x}" for channel in rgb)
|
||
|
||
def _infer_genre_suggestion(self, tone: str, contrast_desc: str, palette_names: List[str]) -> str:
|
||
"""Generate a lightweight genre recommendation from tonal clues."""
|
||
|
||
if tone == "dim" and "high" in contrast_desc:
|
||
return "Could lean into noir, horror, or dramatic lighting."
|
||
if tone == "bright" and "high" in contrast_desc:
|
||
return "Supports energetic action or bold cinematic styles."
|
||
if tone == "bright" and "low" in contrast_desc:
|
||
return "Suited for airy documentary, minimalist, or romantic moods."
|
||
if "blue" in palette_names or "teal" in palette_names:
|
||
return "Great candidate for sci-fi or cyberpunk palettes."
|
||
if "brown" in palette_names or "orange" in palette_names:
|
||
return "Pairs well with vintage, steampunk, or historical vibes."
|
||
return "Flexible genre potential; adapt to project goals."
|
||
|
||
def _resolve_reference_directive_key(self, choice: Optional[str]) -> str:
|
||
"""Map user-facing directive text to an internal key."""
|
||
|
||
if not choice:
|
||
return "auto"
|
||
|
||
normalized = " ".join(choice.strip().lower().replace('-', ' ').split())
|
||
alias_map = {
|
||
"subject only": "subject_only",
|
||
"subject_only": "subject_only",
|
||
"style only": "style_only",
|
||
"style_only": "style_only",
|
||
"lighting only": "lighting_only",
|
||
"lighting_only": "lighting_only"
|
||
}
|
||
|
||
if normalized in alias_map:
|
||
key = alias_map[normalized]
|
||
else:
|
||
key = normalized.replace(" ", "_")
|
||
|
||
if key not in self.reference_directive_configs:
|
||
return "auto"
|
||
return key
|
||
|
||
def _build_reference_plan_entry(self, label: str, choice: Optional[str]) -> Dict[str, Any]:
|
||
"""Create a normalized directive entry for a reference image."""
|
||
|
||
directive_key = self._resolve_reference_directive_key(choice)
|
||
config = self.reference_directive_configs.get(directive_key, self.reference_directive_configs["auto"])
|
||
display = config.get("display") or directive_key.replace('_', ' ').title()
|
||
return {
|
||
"label": label,
|
||
"user_choice": choice,
|
||
"directive_key": directive_key,
|
||
"display": display,
|
||
"config": config
|
||
}
|
||
|
||
def _prepare_reference_plan(self, directive_inputs: List[Tuple[str, str]]) -> List[Dict[str, Any]]:
|
||
"""Normalize user selections into directive plan entries."""
|
||
|
||
plan: List[Dict[str, Any]] = []
|
||
for label, choice in directive_inputs:
|
||
plan.append(self._build_reference_plan_entry(label, choice))
|
||
return plan
|
||
|
||
def _align_reference_plan(self, analyses: List[Dict[str, Any]], plan: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""Align directive plan entries with available analyses, defaulting to auto when missing."""
|
||
|
||
resolved_plan: List[Dict[str, Any]] = []
|
||
for idx, analysis in enumerate(analyses):
|
||
if idx < len(plan):
|
||
resolved_plan.append(plan[idx])
|
||
else:
|
||
label = analysis.get("label", f"Reference {idx + 1}")
|
||
resolved_plan.append(self._build_reference_plan_entry(label, "none"))
|
||
return resolved_plan
|
||
|
||
def _resolve_category_list(self, categories: Union[str, List[str], None]) -> List[str]:
|
||
"""Resolve category hints into a concrete ordered list."""
|
||
|
||
if categories is None:
|
||
return []
|
||
|
||
if isinstance(categories, str):
|
||
cleaned = categories.strip().lower()
|
||
if cleaned == "all":
|
||
return list(self.reference_usage_labels.keys())
|
||
return [cleaned]
|
||
|
||
resolved: List[str] = []
|
||
seen: set = set()
|
||
for category in categories:
|
||
key = str(category).strip().lower()
|
||
if not key:
|
||
continue
|
||
if key == "all":
|
||
return list(self.reference_usage_labels.keys())
|
||
if key not in seen:
|
||
seen.add(key)
|
||
resolved.append(key)
|
||
return resolved
|
||
|
||
def _collect_directive_traits(
|
||
self,
|
||
analysis: Dict[str, Any],
|
||
include_hint: Union[str, List[str], None]
|
||
) -> List[str]:
|
||
"""Gather trait notes that align with the selected directive categories."""
|
||
|
||
categories = self._resolve_category_list(include_hint)
|
||
notes = analysis.get("category_notes", {}) or {}
|
||
traits: List[str] = []
|
||
|
||
for category in categories:
|
||
note = notes.get(category)
|
||
if not note:
|
||
continue
|
||
label = self.reference_usage_labels.get(category, category.replace('_', ' '))
|
||
traits.append(f"{label}: {note}")
|
||
|
||
return traits
|
||
|
||
def _normalize_focus_lines(self, focus_note: Optional[str]) -> List[str]:
|
||
"""Break directive focus text into clean bullet-friendly lines."""
|
||
|
||
if not focus_note:
|
||
return []
|
||
|
||
sanitized = focus_note.replace("•", "\n").replace(" - ", "\n").replace("- ", "\n")
|
||
fragments = [frag.strip(" \t•-") for frag in sanitized.splitlines() if frag.strip(" \t•-")]
|
||
|
||
if not fragments:
|
||
cleaned = focus_note.strip()
|
||
return [cleaned] if cleaned else []
|
||
|
||
if len(fragments) == 1:
|
||
sentences = [seg.strip() for seg in re.split(r'(?<=[.!?])\s+', fragments[0]) if seg.strip()]
|
||
return sentences or fragments
|
||
|
||
return fragments
|
||
|
||
def _clone_analysis_payload(self, analysis: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Copy analysis dictionary while ensuring mutable members are duplicated."""
|
||
|
||
cloned = dict(analysis)
|
||
cloned.pop("llm_logs", None)
|
||
details = analysis.get("details")
|
||
if isinstance(details, list):
|
||
cloned["details"] = list(details)
|
||
elif details is None:
|
||
cloned["details"] = []
|
||
else:
|
||
cloned["details"] = [str(details)]
|
||
|
||
category_notes = analysis.get("category_notes")
|
||
cloned["category_notes"] = dict(category_notes) if isinstance(category_notes, dict) else {}
|
||
return cloned
|
||
|
||
def _append_analysis_detail(self, analysis: Dict[str, Any], detail: str) -> None:
|
||
"""Attach a detail string to the analysis record if it is new."""
|
||
|
||
if not detail:
|
||
return
|
||
|
||
details = analysis.get("details")
|
||
if not isinstance(details, list):
|
||
details = [str(details)] if details else []
|
||
|
||
if detail not in details:
|
||
details.append(detail)
|
||
analysis["details"] = details
|
||
|
||
def _fallback_directive_analysis(self, analysis: Dict[str, Any], focus_text: str) -> str:
|
||
"""Provide a deterministic fallback summary when LLM analysis fails."""
|
||
|
||
summary = analysis.get("summary") or "Reference image supplied."
|
||
focus = focus_text.strip() if focus_text else ""
|
||
if focus and focus.lower() not in summary.lower():
|
||
return f"{summary} Focus: {focus}."
|
||
return summary
|
||
|
||
def _conduct_directive_analysis_llm(
|
||
self,
|
||
llm: Optional[LLMBackend],
|
||
analysis: Dict[str, Any],
|
||
entry: Dict[str, Any]
|
||
) -> Tuple[Dict[str, Any], Dict[str, Any], bool]:
|
||
"""Call the LLM to produce directive-focused reference notes."""
|
||
|
||
cloned = self._clone_analysis_payload(analysis)
|
||
config = entry.get("config", {}) or {}
|
||
|
||
directive_text = config.get("llm_instruction") or config.get("user_guidance") or "Follow the directive."
|
||
focus_text = config.get("analysis_focus") or "Highlight the most useful traits from this reference."
|
||
|
||
label = entry.get("label") or cloned.get("label", "Reference")
|
||
display = entry.get("display") or entry.get("directive_key", "Directive")
|
||
|
||
category_notes = cloned.get("category_notes") or {}
|
||
note_lines: List[str] = []
|
||
for category, note in category_notes.items():
|
||
if not note:
|
||
continue
|
||
label_text = self.reference_usage_labels.get(category, category.replace('_', ' '))
|
||
note_lines.append(f"{label_text}: {note}")
|
||
|
||
details = cloned.get("details")
|
||
if not isinstance(details, list):
|
||
details = [str(details)] if details else []
|
||
|
||
caption_text = cloned.get("vision_caption")
|
||
if caption_text:
|
||
normalized_caption = caption_text.strip()
|
||
details = [
|
||
str(detail)
|
||
for detail in details
|
||
if str(detail).strip() and str(detail).strip() != normalized_caption
|
||
]
|
||
|
||
system_prompt = (
|
||
"You analyze detailed reference captions for a prompt engineer. Provide up to two concise sentences that"
|
||
" explain how to use the full caption while honoring the directive focus. Avoid mentioning pixel dimensions,"
|
||
" aspect ratios, or describing yourself. Do not use bullet points or numbering. Keep all critical subjects intact."
|
||
)
|
||
|
||
lines: List[str] = [
|
||
f"Reference label: {label}",
|
||
f"Directive choice: {display}",
|
||
f"Directive intent: {directive_text}",
|
||
"",
|
||
"Baseline summary:",
|
||
cloned.get("summary", "")
|
||
]
|
||
|
||
if caption_text:
|
||
lines.append("")
|
||
lines.append("Full reference caption (canonical):")
|
||
lines.append(caption_text)
|
||
|
||
if details:
|
||
lines.append("")
|
||
lines.append("Additional analysis notes:")
|
||
lines.extend(f"- {detail}" for detail in details)
|
||
|
||
if note_lines:
|
||
lines.append("")
|
||
lines.append("Category cues:")
|
||
lines.extend(f"- {note}" for note in note_lines)
|
||
|
||
lines.append("")
|
||
lines.append("Focus request:")
|
||
lines.append(f"- {focus_text}")
|
||
lines.append("")
|
||
lines.append("Compose at most two sentences that satisfy the focus. Return plain text without headers.")
|
||
|
||
user_prompt = "\n".join(lines)
|
||
|
||
log_entry = {
|
||
"label": label,
|
||
"directive": display,
|
||
"mode": "directive_analysis",
|
||
"system_prompt": system_prompt,
|
||
"user_prompt": user_prompt,
|
||
"raw_response": None,
|
||
"success": False
|
||
}
|
||
|
||
attempted = bool(llm)
|
||
if not attempted:
|
||
fallback = self._fallback_directive_analysis(cloned, focus_text)
|
||
cloned["directive_analysis"] = fallback
|
||
self._append_analysis_detail(cloned, fallback)
|
||
return cloned, log_entry, attempted
|
||
|
||
try:
|
||
response = llm.send_prompt(system_prompt=system_prompt, user_prompt=user_prompt, max_tokens=220)
|
||
raw = response.get("response", "")
|
||
if response.get("success") and raw:
|
||
cleaned = raw.strip()
|
||
log_entry["raw_response"] = cleaned
|
||
log_entry["success"] = True
|
||
cloned["directive_analysis"] = cleaned
|
||
self._append_analysis_detail(cloned, cleaned)
|
||
return cloned, log_entry, attempted
|
||
if raw:
|
||
log_entry["raw_response"] = raw.strip()
|
||
except Exception as exc:
|
||
print(f"Directive analysis failed: {exc}")
|
||
|
||
fallback = self._fallback_directive_analysis(cloned, focus_text)
|
||
cloned["directive_analysis"] = fallback
|
||
self._append_analysis_detail(cloned, fallback)
|
||
return cloned, log_entry, attempted
|
||
|
||
def _run_reference_directive_analysis(
|
||
self,
|
||
analyses: List[Dict[str, Any]],
|
||
plan: List[Dict[str, Any]],
|
||
llm: Optional[LLMBackend]
|
||
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||
"""Generate directive-driven summaries for each reference before prompt construction."""
|
||
|
||
if not analyses:
|
||
empty_meta = {
|
||
"phase": "directive_analysis",
|
||
"reference_count": 0,
|
||
"llm_queries": 0,
|
||
"llm_successes": 0,
|
||
"llm_logs": []
|
||
}
|
||
return [], empty_meta
|
||
|
||
resolved_plan = self._align_reference_plan(analyses, plan)
|
||
processed: List[Dict[str, Any]] = []
|
||
llm_logs: List[Dict[str, Any]] = []
|
||
llm_queries = 0
|
||
llm_successes = 0
|
||
|
||
for analysis, entry in zip(analyses, resolved_plan):
|
||
analysis_logs = analysis.get("llm_logs") or []
|
||
for pre_log in analysis_logs:
|
||
llm_logs.append(pre_log)
|
||
attempted_flag = pre_log.get("attempted")
|
||
if attempted_flag not in (False, None):
|
||
llm_queries += 1
|
||
if pre_log.get("success"):
|
||
llm_successes += 1
|
||
|
||
if entry.get("directive_key") == "none":
|
||
cloned = self._clone_analysis_payload(analysis)
|
||
cloned.pop("llm_logs", None)
|
||
caption_text = analysis.get("vision_caption") or analysis.get("summary") or "Reference image provided."
|
||
cloned["directive_analysis"] = caption_text
|
||
self._append_analysis_detail(cloned, caption_text)
|
||
processed.append(cloned)
|
||
continue
|
||
|
||
enriched, log_entry, attempted = self._conduct_directive_analysis_llm(llm, analysis, entry)
|
||
processed.append(enriched)
|
||
if log_entry:
|
||
llm_logs.append(log_entry)
|
||
if attempted:
|
||
llm_queries += 1
|
||
if log_entry.get("success"):
|
||
llm_successes += 1
|
||
|
||
meta = {
|
||
"phase": "directive_analysis",
|
||
"reference_count": len(processed),
|
||
"llm_queries": llm_queries,
|
||
"llm_successes": llm_successes,
|
||
"llm_logs": llm_logs
|
||
}
|
||
return processed, meta
|
||
|
||
def _build_reference_guidance(
|
||
self,
|
||
analyses: List[Dict[str, Any]],
|
||
plan: List[Dict[str, Any]],
|
||
llm: LLMBackend,
|
||
prompt_context: str
|
||
) -> Tuple[str, List[str], Dict[str, Any]]:
|
||
"""Generate textual guidance for using reference images and capture LLM usage stats."""
|
||
|
||
if not analyses:
|
||
return "", [], {
|
||
"analysis_method": "none",
|
||
"reference_count": 0,
|
||
"llm_queries": 0,
|
||
"llm_successes": 0,
|
||
"guardrail": self.reference_guardrail_text,
|
||
"directive_labels": [],
|
||
"directives": [],
|
||
"llm_logs": []
|
||
}
|
||
|
||
resolved_plan = self._align_reference_plan(analyses, plan)
|
||
|
||
method = self.default_reference_analysis_method or "sequential_refine"
|
||
if method not in {"single_pass", "sequential_refine"}:
|
||
method = "sequential_refine"
|
||
if not llm:
|
||
method = "single_pass"
|
||
|
||
guidance_lines: List[str] = [
|
||
"Reference guardrails:",
|
||
f"- {self.reference_guardrail_text}",
|
||
"- Integrate each directive once; avoid repeating identical phrasing.",
|
||
"- Preserve every major subject and environmental cue from the provided captions."
|
||
]
|
||
|
||
guidance_lines.append("")
|
||
|
||
reference_notes: List[str] = []
|
||
reference_logs: List[Dict[str, Any]] = []
|
||
previous_guidance = ""
|
||
llm_queries = 0
|
||
llm_successes = 0
|
||
vision_captions: List[str] = []
|
||
vision_caption_errors: List[str] = []
|
||
|
||
for analysis, entry in zip(analyses, resolved_plan):
|
||
note_label = entry.get('label', analysis.get('label', 'Reference'))
|
||
note_display = entry.get('display', 'Auto')
|
||
focus_lines = self._normalize_focus_lines(analysis.get("directive_analysis"))
|
||
focus_block: List[str] = []
|
||
if focus_lines:
|
||
focus_header = f"{note_label} directive focus:"
|
||
focus_block.append(focus_header)
|
||
focus_block.extend(f"- {line}" for line in focus_lines)
|
||
|
||
if method == "sequential_refine":
|
||
refined, success, log_entry = self._refine_reference_with_llm(llm, analysis, entry, previous_guidance)
|
||
llm_queries += 1
|
||
if success:
|
||
llm_successes += 1
|
||
else:
|
||
refined = self._compose_reference_line(analysis, entry)
|
||
success = False
|
||
log_entry = {
|
||
"label": entry.get("label", analysis.get("label", "Reference")),
|
||
"directive": entry.get("display"),
|
||
"mode": "single_pass",
|
||
"system_prompt": None,
|
||
"user_prompt": None,
|
||
"raw_response": None,
|
||
"success": False
|
||
}
|
||
|
||
block_lines: List[str] = []
|
||
if focus_block:
|
||
guidance_lines.extend(focus_block)
|
||
block_lines.extend(focus_block)
|
||
|
||
guidance_lines.append(refined)
|
||
block_lines.append(refined)
|
||
|
||
config = entry.get('config', {})
|
||
summary_note = config.get('user_summary') or config.get('user_guidance') or ""
|
||
focus_note = focus_lines[0] if focus_lines else ""
|
||
note_parts: List[str] = []
|
||
if summary_note:
|
||
note_parts.append(summary_note)
|
||
if focus_note:
|
||
note_parts.append(f"Focus: {focus_note}")
|
||
|
||
caption_text = analysis.get("vision_caption")
|
||
if caption_text:
|
||
vision_captions.append(caption_text)
|
||
snippet = caption_text if len(caption_text) <= 220 else caption_text[:217] + "…"
|
||
note_parts.append(f"Caption: {snippet}")
|
||
elif analysis.get("vision_caption_error"):
|
||
error_entry = analysis.get("vision_caption_error")
|
||
if error_entry:
|
||
vision_caption_errors.append(str(error_entry))
|
||
|
||
if note_parts:
|
||
reference_notes.append(f"{note_label} ({note_display}): " + " | ".join(note_parts))
|
||
else:
|
||
reference_notes.append(f"{note_label} ({note_display})")
|
||
|
||
previous_guidance += "\n".join(block_lines) + "\n"
|
||
reference_logs.append(log_entry)
|
||
|
||
if prompt_context == "reference_modification":
|
||
guidance_lines.append(
|
||
"Focus on adjusting existing reference traits rather than inventing wholly new scenes."
|
||
)
|
||
|
||
return "\n".join(guidance_lines).strip(), reference_notes, {
|
||
"analysis_method": method,
|
||
"reference_count": len(analyses),
|
||
"llm_queries": llm_queries,
|
||
"llm_successes": llm_successes,
|
||
"guardrail": self.reference_guardrail_text,
|
||
"directive_labels": [entry.get("display") for entry in resolved_plan],
|
||
"directives": [entry.get("directive_key") for entry in resolved_plan],
|
||
"llm_logs": reference_logs,
|
||
"vision_captions": vision_captions,
|
||
"vision_caption_errors": vision_caption_errors
|
||
}
|
||
|
||
def _merge_reference_metadata(
|
||
self,
|
||
plan: List[Dict[str, Any]],
|
||
directive_meta: Dict[str, Any],
|
||
guidance_meta: Dict[str, Any]
|
||
) -> Dict[str, Any]:
|
||
"""Combine directive analysis and guidance metadata for downstream reporting."""
|
||
|
||
directives = [entry.get("directive_key") for entry in plan]
|
||
directive_labels = [entry.get("display") for entry in plan]
|
||
|
||
directive_queries = directive_meta.get("llm_queries", 0)
|
||
guidance_queries = guidance_meta.get("llm_queries", 0)
|
||
directive_success = directive_meta.get("llm_successes", 0)
|
||
guidance_success = guidance_meta.get("llm_successes", 0)
|
||
|
||
reference_count = max(
|
||
directive_meta.get("reference_count", 0),
|
||
guidance_meta.get("reference_count", 0),
|
||
len(plan)
|
||
)
|
||
|
||
combined_logs = (directive_meta.get("llm_logs") or []) + (guidance_meta.get("llm_logs") or [])
|
||
analysis_method = guidance_meta.get("analysis_method") or (
|
||
self.default_reference_analysis_method or "sequential_refine"
|
||
)
|
||
|
||
if reference_count == 0:
|
||
analysis_method = "none"
|
||
|
||
return {
|
||
"analysis_method": analysis_method,
|
||
"reference_count": reference_count,
|
||
"llm_queries": directive_queries + guidance_queries,
|
||
"llm_successes": directive_success + guidance_success,
|
||
"guardrail": self.reference_guardrail_text,
|
||
"directive_labels": directive_labels,
|
||
"directives": directives,
|
||
"llm_logs": combined_logs,
|
||
"analysis_phase": directive_meta,
|
||
"guidance_phase": guidance_meta,
|
||
"vision_captions": guidance_meta.get("vision_captions", []),
|
||
"vision_caption_errors": guidance_meta.get("vision_caption_errors", [])
|
||
}
|
||
|
||
def _resolve_seed_value(self, mode: str, requested_seed: int) -> Tuple[int, str]:
|
||
"""Resolve the actual seed value based on the selected seed mode."""
|
||
|
||
normalized_mode = (mode or "random").strip().lower()
|
||
valid_modes = {"random", "fixed", "increment", "decrement"}
|
||
if normalized_mode not in valid_modes:
|
||
normalized_mode = "random"
|
||
|
||
max_seed = 2_147_483_647
|
||
system_rng = random.SystemRandom()
|
||
|
||
state = self._seed_state
|
||
last_mode = state.get("last_mode")
|
||
last_seed = state.get("last_seed")
|
||
last_input = state.get("last_input")
|
||
|
||
if requested_seed is None:
|
||
requested_seed = -1
|
||
else:
|
||
requested_seed = int(requested_seed)
|
||
seed_specified = requested_seed >= 0
|
||
|
||
def fresh_seed() -> int:
|
||
if seed_specified:
|
||
return requested_seed
|
||
return system_rng.randint(0, max_seed)
|
||
|
||
if normalized_mode == "fixed":
|
||
if seed_specified:
|
||
seed_value = requested_seed
|
||
elif last_mode == "fixed" and last_input == requested_seed and last_seed is not None:
|
||
seed_value = int(last_seed)
|
||
else:
|
||
seed_value = fresh_seed()
|
||
elif normalized_mode == "increment":
|
||
if last_mode == "increment" and last_input == requested_seed and last_seed is not None:
|
||
seed_value = int(last_seed) + 1
|
||
if seed_value > max_seed:
|
||
seed_value = 0
|
||
else:
|
||
seed_value = fresh_seed()
|
||
elif normalized_mode == "decrement":
|
||
if last_mode == "decrement" and last_input == requested_seed and last_seed is not None:
|
||
seed_value = int(last_seed) - 1
|
||
if seed_value < 0:
|
||
seed_value = max_seed
|
||
else:
|
||
seed_value = fresh_seed()
|
||
else: # random or fallback
|
||
seed_value = system_rng.randint(0, max_seed)
|
||
normalized_mode = "random"
|
||
|
||
state["last_seed"] = int(seed_value)
|
||
state["last_mode"] = normalized_mode
|
||
state["last_input"] = requested_seed
|
||
|
||
return int(seed_value), normalized_mode
|
||
|
||
def _compose_reference_line(self, analysis: Dict[str, Any], entry: Dict[str, Any]) -> str:
|
||
"""Compose a descriptive line for a reference image using directive presets."""
|
||
|
||
label = analysis.get("label", "Reference")
|
||
summary = analysis.get("summary", f"{label}: reference image provided")
|
||
config = entry.get("config", {})
|
||
include_hint = config.get("include_categories")
|
||
exclude_categories = config.get("exclude_categories", []) or []
|
||
extra_note = config.get("extra_notes")
|
||
|
||
lines = [f"- {summary}"]
|
||
directive_text = config.get("user_guidance")
|
||
if directive_text:
|
||
lines.append(f" • Directive: {directive_text}")
|
||
|
||
traits = self._collect_directive_traits(analysis, include_hint)
|
||
for trait in traits:
|
||
lines.append(f" • {trait}")
|
||
|
||
focus_lines = self._normalize_focus_lines(analysis.get("directive_analysis"))
|
||
for focus in focus_lines:
|
||
lines.append(f" • Focus note: {focus}")
|
||
|
||
for category in exclude_categories:
|
||
label_text = self.reference_usage_labels.get(category, category.replace('_', ' '))
|
||
lines.append(f" • Avoid mirroring {label_text}; generate a fresh interpretation.")
|
||
|
||
if extra_note:
|
||
lines.append(f" • {extra_note}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
def _refine_reference_with_llm(
|
||
self,
|
||
llm: LLMBackend,
|
||
analysis: Dict[str, Any],
|
||
entry: Dict[str, Any],
|
||
previous_guidance: str
|
||
) -> Tuple[str, bool, Dict[str, Any]]:
|
||
"""Request a compact reference summary from the LLM when sequential refinement is desired."""
|
||
|
||
config = entry.get("config", {})
|
||
include_hint = config.get("include_categories")
|
||
exclude_categories = config.get("exclude_categories", []) or []
|
||
directive_text = config.get("llm_instruction") or config.get("user_guidance", "Follow the directive.")
|
||
|
||
emphasis_lines = self._collect_directive_traits(analysis, include_hint)
|
||
avoid_lines = []
|
||
for category in exclude_categories:
|
||
label_text = self.reference_usage_labels.get(category, category.replace('_', ' '))
|
||
avoid_lines.append(f"Avoid copying {label_text}; invent something new.")
|
||
|
||
detail_entries = analysis.get("details", [])
|
||
if not isinstance(detail_entries, (list, tuple)):
|
||
detail_entries = [str(detail_entries)] if detail_entries else []
|
||
|
||
system_prompt = (
|
||
"You help transform reference analysis into concise prompt guidance. Provide at most two sentences. "
|
||
"Do not mention pixel dimensions, resolutions, or aspect ratios. Avoid repeating phrases verbatim."
|
||
)
|
||
|
||
lines: List[str] = [
|
||
f"Reference label: {analysis.get('label', 'Reference')}",
|
||
f"Directive: {directive_text}",
|
||
f"Summary: {analysis.get('summary', '')}"
|
||
]
|
||
|
||
if emphasis_lines:
|
||
lines.append("Focus cues:")
|
||
lines.extend(f"- {cue}" for cue in emphasis_lines)
|
||
|
||
if avoid_lines:
|
||
lines.append("Avoid copying:")
|
||
lines.extend(f"- {cue}" for cue in avoid_lines)
|
||
|
||
if detail_entries:
|
||
lines.append("Additional observations:")
|
||
lines.extend(f"- {str(detail)}" for detail in detail_entries)
|
||
|
||
if previous_guidance:
|
||
lines.append("Guidance already covered for earlier references:")
|
||
lines.append(previous_guidance.strip())
|
||
|
||
lines.append("Write guidance that honors the directive without repeating earlier sentences.")
|
||
user_prompt = "\n".join(lines)
|
||
|
||
log_entry = {
|
||
"label": analysis.get("label", "Reference"),
|
||
"directive": entry.get("display"),
|
||
"mode": "sequential_refine",
|
||
"system_prompt": system_prompt,
|
||
"user_prompt": user_prompt,
|
||
"raw_response": None,
|
||
"success": False
|
||
}
|
||
|
||
try:
|
||
response = llm.send_prompt(system_prompt=system_prompt, user_prompt=user_prompt, max_tokens=240)
|
||
raw_payload = response.get("response")
|
||
if response.get("success") and raw_payload:
|
||
raw = raw_payload.strip()
|
||
log_entry["raw_response"] = raw
|
||
log_entry["success"] = True
|
||
return "- " + raw, True, log_entry
|
||
if raw_payload:
|
||
log_entry["raw_response"] = raw_payload.strip()
|
||
except Exception as exc:
|
||
print(f"Reference refinement failed: {exc}")
|
||
|
||
fallback_text = self._compose_reference_line(analysis, entry)
|
||
return fallback_text, False, log_entry
|
||
|
||
def _generate_creative_brainstorm(self, text_prompt: str, randomness_mode: str) -> Optional[str]:
|
||
"""Produce creative guidance snippets based on the selected randomness level."""
|
||
|
||
randomness_mode = (randomness_mode or "off").lower()
|
||
if randomness_mode in {"off", "none"}:
|
||
return None
|
||
|
||
base_subject = text_prompt.split(',')[0].strip() if text_prompt else "the subject"
|
||
|
||
if randomness_mode == "subtle":
|
||
return (
|
||
"Creative flourish: add gentle ambient storytelling cues (background sounds, faint weather, or "
|
||
"minor props) that enrich the scene without changing the main subject."
|
||
)
|
||
|
||
if randomness_mode == "moderate":
|
||
suggestion = random.choice(self.random_story_settings)
|
||
companion = random.choice(self.random_story_companions)
|
||
return (
|
||
"Creative prompt idea: place {subject} within {setting} accompanied by {companion}. "
|
||
"Retain the original tone but add cohesive environmental storytelling."
|
||
).format(subject=base_subject, setting=suggestion, companion=companion)
|
||
|
||
if randomness_mode in {"bold", "storyteller", "chaotic"}:
|
||
setting = random.choice(self.random_story_settings)
|
||
companion = random.choice(self.random_story_companions)
|
||
conflict = random.choice(self.random_story_conflicts)
|
||
tone = "Embrace surreal twists" if randomness_mode == "chaotic" else "Craft a vivid narrative"
|
||
return (
|
||
f"{tone}: imagine {base_subject} within {setting}, joined by {companion}, {conflict}. "
|
||
"Blend this storyline into the enhanced prompt while preserving user intent."
|
||
)
|
||
|
||
return None
|
||
|
||
def _determine_max_tokens(
|
||
self,
|
||
platform_config: Dict,
|
||
creative_randomness: Optional[str]
|
||
) -> int:
|
||
"""Adjust LLM max tokens according to platform and desired verbosity."""
|
||
|
||
max_tokens_hint = platform_config.get("max_tokens")
|
||
max_words_hint = platform_config.get("max_words")
|
||
|
||
if max_tokens_hint:
|
||
base_tokens = int(max_tokens_hint)
|
||
elif max_words_hint:
|
||
base_tokens = int(max_words_hint * 1.45)
|
||
else:
|
||
base_tokens = 1000
|
||
|
||
if creative_randomness in {"bold", "storyteller", "chaotic"}:
|
||
base_tokens += 120
|
||
|
||
# Provide additional headroom for extremely long prompts
|
||
return min(base_tokens + 200, 1800)
|
||
|
||
def _cap_tokens_for_backend(
|
||
self,
|
||
backend: str,
|
||
model_name: Optional[str],
|
||
requested_tokens: int
|
||
) -> Tuple[int, Optional[str]]:
|
||
"""Apply backend-aware caps to avoid overloading smaller local models."""
|
||
|
||
backend_lower = (backend or "").lower()
|
||
model_lower = (model_name or "").lower()
|
||
adjusted = int(requested_tokens)
|
||
cap_reason: Optional[str] = None
|
||
|
||
def apply_cap(limit: int, reason: str) -> None:
|
||
nonlocal adjusted, cap_reason
|
||
if adjusted > limit:
|
||
adjusted = limit
|
||
if cap_reason is None:
|
||
cap_reason = reason
|
||
|
||
if backend_lower == "qwen3_vl":
|
||
apply_cap(512, "Capped max tokens to 512 for local Qwen3-VL stability.")
|
||
elif backend_lower in {"lm_studio", "ollama"}:
|
||
small_signals = ["1.5b", "2b", "3b", "4b", "tiny", "mini", "phi-2", "phi-3", "phi3", "smol"]
|
||
medium_signals = ["5b", "6b", "7b", "8b"]
|
||
if any(sig in model_lower for sig in small_signals):
|
||
apply_cap(512, f"Capped max tokens for small model '{model_name}'.")
|
||
elif any(sig in model_lower for sig in medium_signals):
|
||
apply_cap(768, f"Capped max tokens for mid-size model '{model_name}'.")
|
||
apply_cap(1024, f"Capped max tokens to 1024 for backend '{backend}'.")
|
||
else:
|
||
apply_cap(1024, "Capped max tokens to 1024 for stability.")
|
||
|
||
return adjusted, cap_reason
|
||
|
||
def _call_main_llm_with_retries(
|
||
self,
|
||
llm: LLMBackend,
|
||
system_prompt: str,
|
||
user_prompt: str,
|
||
max_tokens: int,
|
||
backend_params: Dict[str, Any]
|
||
) -> Tuple[Dict[str, Any], LLMBackend, List[Dict[str, Any]]]:
|
||
"""Send prompt with limited retries and adaptive token ceilings."""
|
||
|
||
attempt_tokens: List[int] = [int(max_tokens)]
|
||
fallback_candidates = [max(240, min(max_tokens, 640)), 320]
|
||
for candidate in fallback_candidates:
|
||
if candidate < attempt_tokens[0]:
|
||
attempt_tokens.append(int(candidate))
|
||
# Preserve order while removing duplicates
|
||
seen: set = set()
|
||
ordered_tokens: List[int] = []
|
||
for value in attempt_tokens:
|
||
if value not in seen:
|
||
ordered_tokens.append(value)
|
||
seen.add(value)
|
||
|
||
attempts_log: List[Dict[str, Any]] = []
|
||
current_llm = llm
|
||
last_response: Dict[str, Any] = {"success": False, "response": "", "error": "No attempt"}
|
||
|
||
for attempt_index, tokens in enumerate(ordered_tokens):
|
||
response = current_llm.send_prompt(
|
||
system_prompt=system_prompt,
|
||
user_prompt=user_prompt,
|
||
max_tokens=tokens
|
||
)
|
||
last_response = response
|
||
attempt_info = {
|
||
"attempt": attempt_index + 1,
|
||
"backend": current_llm.backend_type,
|
||
"model": getattr(current_llm, "model_name", None),
|
||
"max_tokens": tokens,
|
||
"success": bool(response.get("success")),
|
||
"error": response.get("error"),
|
||
"empty_response": not bool((response.get("response") or "").strip())
|
||
}
|
||
attempts_log.append(attempt_info)
|
||
|
||
if response.get("success") and (response.get("response") or "").strip():
|
||
attempt_info["used"] = True
|
||
return response, current_llm, attempts_log
|
||
|
||
if attempt_index < len(ordered_tokens) - 1:
|
||
try:
|
||
current_llm = LLMBackend(
|
||
backend_type=backend_params["backend_type"],
|
||
endpoint=backend_params["endpoint"],
|
||
model_name=None,
|
||
temperature=backend_params["temperature"]
|
||
)
|
||
except Exception as exc:
|
||
attempts_log.append({
|
||
"attempt": attempt_index + 1,
|
||
"backend": backend_params.get("backend_type"),
|
||
"max_tokens": tokens,
|
||
"success": False,
|
||
"error": f"Backend reinit failed: {exc}",
|
||
"reinit_failed": True
|
||
})
|
||
# Continue with existing llm instance if reinit fails
|
||
|
||
return last_response, current_llm, attempts_log
|
||
|
||
def _settings_to_phrases(self, settings: Dict[str, str]) -> List[str]:
|
||
"""Convert resolved settings into descriptive phrases for fallbacks."""
|
||
|
||
phrases: List[str] = []
|
||
mapping = {
|
||
"camera_angle": "shot from {value}",
|
||
"composition": "composition guided by {value}",
|
||
"lighting_source": "lit by {value}",
|
||
"lighting_quality": "lighting quality is {value}",
|
||
"time_of_day": "set during {value}",
|
||
"historical_period": "evokes the {value}",
|
||
"weather": "{value} conditions",
|
||
"color_mood": "{value} color palette",
|
||
"genre_style": "{value} tone",
|
||
"subject_framing": "{value} framing",
|
||
"subject_pose": "subject posed {value}"
|
||
}
|
||
|
||
for key, template in mapping.items():
|
||
value = settings.get(key)
|
||
if not value:
|
||
continue
|
||
lowered = value.lower()
|
||
if "auto" in lowered or "none" in lowered:
|
||
continue
|
||
phrase = template.format(value=value)
|
||
if phrase not in phrases:
|
||
phrases.append(phrase)
|
||
|
||
return phrases
|
||
|
||
def _build_deterministic_fallback_prompt(
|
||
self,
|
||
base_prompt: str,
|
||
platform: str,
|
||
platform_config: Dict[str, Any],
|
||
settings: Dict[str, str],
|
||
reference_notes: List[str]
|
||
) -> Tuple[str, Dict[str, Any]]:
|
||
"""Synthesize a deterministic enhancement when the main LLM fails."""
|
||
|
||
base = base_prompt.strip() or "Describe the requested scene vividly"
|
||
phrases = self._settings_to_phrases(settings)
|
||
reference_phrases: List[str] = []
|
||
for note in reference_notes:
|
||
if not note:
|
||
continue
|
||
cleaned = note.split(":", 1)[-1].strip() if ":" in note else note.strip()
|
||
if cleaned and cleaned not in reference_phrases:
|
||
reference_phrases.append(cleaned)
|
||
|
||
combined = phrases + reference_phrases
|
||
meta = {
|
||
"setting_phrases": len(phrases),
|
||
"reference_phrases": len(reference_phrases),
|
||
"combined_total": len(combined)
|
||
}
|
||
|
||
if not combined:
|
||
return base, meta
|
||
|
||
addition = ", ".join(combined[:8])
|
||
|
||
if platform == "pony":
|
||
normalized = base
|
||
prefix_tokens = ["score_9", "score_8_up", "score_7_up"]
|
||
normalized_lower = normalized.lower()
|
||
missing_prefix = [token for token in prefix_tokens if token not in normalized_lower]
|
||
if missing_prefix:
|
||
normalized = ", ".join(missing_prefix + [normalized])
|
||
if not normalized.endswith(","):
|
||
normalized = normalized.rstrip() + ","
|
||
fallback_prompt = f"{normalized} {addition}".strip()
|
||
else:
|
||
cleaned_base = base.rstrip("., ")
|
||
fallback_prompt = f"{cleaned_base}. {addition}."
|
||
|
||
return fallback_prompt, meta
|
||
|
||
def _ensure_prompt_density(
|
||
self,
|
||
prompt: str,
|
||
platform: str,
|
||
platform_config: Dict[str, Any],
|
||
reference_notes: List[str],
|
||
settings: Dict[str, str]
|
||
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||
"""Pad prompts that land well below the target word count."""
|
||
|
||
target_words = platform_config.get("max_words")
|
||
if not target_words or target_words < 60:
|
||
return prompt, None
|
||
|
||
# A profile states its own lower bound; the old 80-word floor padded short-prompt models past their target.
|
||
min_words = platform_config.get("min_words") or max(80, int(target_words * 0.7))
|
||
words_before = len(prompt.split())
|
||
if words_before >= min_words:
|
||
return prompt, None
|
||
|
||
additions: List[str] = []
|
||
for note in reference_notes:
|
||
if not note:
|
||
continue
|
||
cleaned = note.split(":", 1)[-1].strip() if ":" in note else note.strip()
|
||
if cleaned and cleaned not in additions:
|
||
additions.append(cleaned)
|
||
if words_before + sum(len(entry.split()) for entry in additions) >= min_words:
|
||
break
|
||
|
||
if words_before + sum(len(entry.split()) for entry in additions) < min_words:
|
||
for phrase in self._settings_to_phrases(settings):
|
||
if phrase and phrase not in additions:
|
||
additions.append(phrase)
|
||
if words_before + sum(len(entry.split()) for entry in additions) >= min_words:
|
||
break
|
||
|
||
if not additions:
|
||
return prompt, None
|
||
|
||
if platform == "pony":
|
||
updated = prompt.rstrip()
|
||
if not updated.endswith(","):
|
||
updated = updated.rstrip("., ") + ","
|
||
updated = f"{updated} {', '.join(additions)}"
|
||
else:
|
||
cleaned = prompt.rstrip(".")
|
||
extra = "; ".join(additions)
|
||
updated = f"{cleaned}. {extra}."
|
||
|
||
density_meta = {
|
||
"min_words": min_words,
|
||
"before_words": words_before,
|
||
"after_words": len(updated.split()),
|
||
"added_phrases": len(additions)
|
||
}
|
||
|
||
return updated, density_meta
|
||
|
||
def _build_system_prompt(
|
||
self,
|
||
platform_key: str,
|
||
platform_config: Dict,
|
||
settings: Dict,
|
||
reference_plan: List[Dict[str, Any]],
|
||
prompt_context: str
|
||
) -> str:
|
||
"""Build LLM system prompt with platform-specific instructions"""
|
||
quality_emphasis = bool(platform_config.get("quality_emphasis", True))
|
||
|
||
platform_name = platform_config["name"]
|
||
platform = platform_config.get("prompt_style", "natural")
|
||
target_words = platform_config.get("max_words")
|
||
min_word_goal: Optional[int] = None
|
||
profile_min_words = platform_config.get("min_words")
|
||
if isinstance(profile_min_words, (int, float)) and profile_min_words > 0:
|
||
# Profiles state the lower bound themselves; the old 80-word floor overshoots short-prompt models.
|
||
min_word_goal = int(profile_min_words)
|
||
elif isinstance(target_words, (int, float)) and target_words >= 60:
|
||
min_word_goal = max(80, int(target_words * 0.75))
|
||
|
||
prompt = f"""You are an expert prompt engineer for {platform_name} image generation.
|
||
|
||
CRITICAL OUTPUT RULES:
|
||
1. Output ONLY the final prompt text - no labels, explanations, or meta-commentary
|
||
2. Do NOT include phrases like "Here is...", "Prompt:", etc.
|
||
3. Start directly with the image description
|
||
4. Follow the platform-specific format precisely
|
||
5. Never mention source image dimensions, aspect ratios, or pixel counts
|
||
6. Keep the user's base prompt concept central; additions must support rather than replace it
|
||
7. Treat every reference directive and focus note from the user message as mandatory content; weave them into the final prompt exactly once.
|
||
|
||
TARGET PLATFORM: {platform_name}
|
||
Description: {platform_config['description']}
|
||
Prompting Style: {platform_config['prompt_style']}
|
||
Optimal Length: {platform_config['optimal_length']}
|
||
|
||
"""
|
||
prompt += "\nOUTPUT INTENSITY GUIDANCE:\n"
|
||
if min_word_goal:
|
||
prompt += f"- Minimum acceptable length: {min_word_goal} words. Falling short counts as a failure.\n"
|
||
else:
|
||
prompt += "- Deliver a multi-sentence, richly layered description (no terse summaries).\n"
|
||
|
||
if reference_plan:
|
||
prompt += "- Dedicate vivid language to every reference directive so each image influences the result.\n"
|
||
if len(reference_plan) > 1:
|
||
prompt += "- Keep reference-derived cues distinct; do not merge them into a single generic sentence.\n"
|
||
|
||
if platform_key == "pony":
|
||
prompt += (
|
||
"- Start with the score tags exactly once, then shift into flowing natural-language prose.\n"
|
||
"- After the score tags, produce an expansive narrative covering subject, wardrobe, environment, lighting, and atmosphere. Sparse checklists are unacceptable.\n"
|
||
)
|
||
if reference_plan:
|
||
prompt += "- When references are provided, weave their traits into luxuriant supporting clauses for extra detail.\n"
|
||
prompt += "\n"
|
||
|
||
# Add platform-specific preferences
|
||
if platform_config.get("preferences"):
|
||
prompt += "\nPLATFORM REQUIREMENTS:\n"
|
||
for pref in platform_config["preferences"]:
|
||
prompt += f"- {pref}\n"
|
||
|
||
# Add quality tokens if enabled
|
||
if quality_emphasis and platform_config.get("quality_tokens"):
|
||
prompt += f"\nQUALITY TOKENS (use appropriately): {', '.join(platform_config['quality_tokens'][:8])}\n"
|
||
|
||
# Add required tokens for specific platforms
|
||
if platform_config.get("required_positive"):
|
||
prompt += f"\nREQUIRED TOKENS (must include): {', '.join(platform_config['required_positive'])}\n"
|
||
|
||
# Add things to avoid
|
||
if platform_config.get("avoid"):
|
||
prompt += "\nAVOID:\n"
|
||
for avoid in platform_config["avoid"]:
|
||
prompt += f"- {avoid}\n"
|
||
|
||
# Add user-specified settings
|
||
prompt += "\n=== USER REQUIREMENTS ===\n"
|
||
prompt += "Incorporate these settings into the prompt:\n\n"
|
||
|
||
# Length and detail expectations (always max detail)
|
||
length_guidance = platform_config.get("length_guidance")
|
||
if length_guidance:
|
||
prompt += f"TARGET LENGTH: {length_guidance}\n"
|
||
|
||
detail_expectation = platform_config.get("detail_expectation")
|
||
if detail_expectation:
|
||
prompt += f"DETAIL EXPECTATION: {detail_expectation}\n"
|
||
|
||
max_words = platform_config.get("max_words")
|
||
if max_words:
|
||
floor_words = int(max_words * 0.6)
|
||
if profile_min_words:
|
||
floor_words = int(profile_min_words)
|
||
elif min_word_goal and min_word_goal > floor_words:
|
||
floor_words = min_word_goal
|
||
prompt += f"ABSOLUTE MINIMUM DETAIL: deliver no fewer than {floor_words} words.\n\n"
|
||
else:
|
||
prompt += "Ensure the description is long-form and exhaustive.\n\n"
|
||
# Handle genre/style if specified
|
||
genre = settings.get("genre_style", "")
|
||
if genre and "auto" not in genre.lower() and "none" not in genre.lower():
|
||
genre_guidance = {
|
||
"surreal": "dreamlike, unexpected juxtapositions, reality-bending elements",
|
||
"cinematic": "film-like quality, dramatic lighting, professional composition",
|
||
"dramatic": "high contrast, emotional intensity, dynamic tension",
|
||
"action": "dynamic motion, energy, movement, intensity",
|
||
"humorous": "playful, whimsical, lighthearted, amusing elements",
|
||
"indie": "artistic, unconventional, creative freedom, unique perspective",
|
||
"horror": "dark atmosphere, ominous mood, eerie elements, tension",
|
||
"scifi": "futuristic, technology, otherworldly, advanced elements",
|
||
"romantic": "soft, intimate, emotional warmth, tender mood",
|
||
"x-rated": "mature themes, adult content, sensual elements",
|
||
"pg": "family-friendly, clean, wholesome, appropriate for all ages",
|
||
"artistic": "creative interpretation, aesthetic focus, expressive",
|
||
"documentary": "realistic, authentic, unposed, natural",
|
||
"minimalist": "simple, clean, essential elements only, negative space",
|
||
"maximalist": "rich details, complex, layered, ornate",
|
||
"vintage": "classic, retro, aged aesthetic, nostalgic feel",
|
||
"modern": "contemporary, current, sleek, clean lines",
|
||
"fantasy": "magical, mythical, imaginative, enchanted",
|
||
"noir": "dark, moody, high contrast shadows, mystery",
|
||
"cyberpunk": "neon, futuristic dystopia, tech, gritty urban",
|
||
"steampunk": "retro-futuristic contraptions, brass machinery, victorian flair",
|
||
"dieselpunk": "industrial grit, roaring engines, interwar aesthetics",
|
||
"mythic": "legendary scale, archetypal symbolism, timeless myth",
|
||
"gothic": "dramatic architecture, chiaroscuro mood, romantic darkness",
|
||
"art deco": "sleek geometry, metallic sheen, roaring twenties glamour",
|
||
"retro futurism": "optimistic vintage sci-fi, bold colors, nostalgic futurism"
|
||
}
|
||
style_desc = genre_guidance.get(genre, genre)
|
||
if genre.strip().lower() == "surreal" and getattr(self, "_surreal_block", ""):
|
||
prompt += f"STYLE/GENRE: surreal - as a procedure, not a mood:\n{self._surreal_block}\n\n"
|
||
else:
|
||
prompt += f"STYLE/GENRE: {genre} - Infuse the prompt with {style_desc}\n\n"
|
||
|
||
# Prompt context guidance
|
||
context_map = {
|
||
"expand_short_prompt": "User provided a shorthand idea. Expand it into a full, production-ready description.",
|
||
"finish_opening_line": "Treat input as the opening line of the prompt. Continue and embellish it coherently.",
|
||
"prompt_from_item_list": "User supplied a checklist. Transform it into a cohesive narrative scene.",
|
||
"modify_reference_image": "Focus on altering specific parts of the reference imagery as requested.",
|
||
"enhance_reference_image": "Use reference images as a base and enrich them with new imaginative elements.",
|
||
# Legacy mappings for backward compatibility
|
||
"abbreviated_prompt": "User provided a shorthand idea. Expand it into a full, production-ready description.",
|
||
"prompt_seed": "Treat input as the opening line of the prompt. Continue and embellish it coherently.",
|
||
"item_list": "User supplied a checklist. Transform it into a cohesive narrative scene.",
|
||
"reference_modification": "Focus on altering specific parts of the reference imagery as requested.",
|
||
"reference_expansion": "Use reference images as a base and enrich them with new imaginative elements."
|
||
}
|
||
if prompt_context in context_map:
|
||
prompt += f"PROMPT CONTEXT: {context_map[prompt_context]}\n\n"
|
||
|
||
# Historical period guidance
|
||
historical = settings.get("historical_period")
|
||
if historical and "auto" not in historical.lower() and "none" not in historical.lower():
|
||
prompt += f"TIME PERIOD: Align visuals with the {historical}.\n\n"
|
||
|
||
# Creative randomness guidance
|
||
creativity = settings.get("creative_randomness", "off")
|
||
if creativity in self.creative_randomness_modes and creativity not in ["off", "none"]:
|
||
prompt += f"CREATIVE RANDOMNESS ({creativity.upper()}): {self.creative_randomness_modes[creativity]}\n"
|
||
prompt += "Blend surprise elements while keeping the requested subject recognizable.\n\n"
|
||
|
||
# Base prompt reinforcement
|
||
prompt += "\nBASE PROMPT PRIORITY:\n"
|
||
prompt += "- The user's text prompt is the authoritative subject. Preserve its characters, actions, and tone.\n"
|
||
prompt += "- If creative randomness or references introduce new ideas, they must enhance (not replace) the base concept.\n"
|
||
prompt += "- Reference directives override conflicting improvisations; missing them counts as failing the task.\n"
|
||
|
||
# Reference usage guidance
|
||
if reference_plan:
|
||
prompt += "REFERENCE IMAGE GUIDELINES:\n"
|
||
prompt += f"- {self.reference_guardrail_text}\n"
|
||
prompt += "- Integrate guidance once within the final prompt; do not repeat phrases verbatim.\n"
|
||
for entry in reference_plan:
|
||
config = entry.get("config", {})
|
||
display = entry.get("display", "Reference")
|
||
instruction = config.get("llm_instruction") or config.get("user_guidance")
|
||
label = entry.get("label", "Reference")
|
||
if instruction:
|
||
prompt += f"- {label} ({display}): {instruction}\n"
|
||
prompt += "\n"
|
||
|
||
for key, value in settings.items():
|
||
if key in [
|
||
"genre_style",
|
||
"creative_randomness",
|
||
"prompt_context",
|
||
"historical_period",
|
||
"length_mode",
|
||
"detail_mode"
|
||
]:
|
||
continue
|
||
if value and "none" not in value.lower() and "auto" not in value.lower():
|
||
label = key.replace("_", " ").title()
|
||
prompt += f"- {label}: {value}\n"
|
||
|
||
# Platform-specific format instructions: from the platform's profile when there is one
|
||
profile_format = platform_config.get("format_instructions", "")
|
||
if profile_format and platform_key != "pony":
|
||
prompt += f"\n{profile_format}"
|
||
if profile_min_words and max_words:
|
||
prompt += f"- Length: {int(profile_min_words)}-{int(max_words)} words. Do not exceed the upper figure.\n"
|
||
|
||
elif "pony" in platform_name.lower():
|
||
prompt += """
|
||
PONY DIFFUSION FORMAT (CRITICAL):
|
||
- MUST START with exactly: score_9, score_8_up, score_7_up
|
||
- After score tags, use NATURAL LANGUAGE descriptions (NO underscores)
|
||
- Comma-separated detailed phrases
|
||
- DO NOT use danbooru tag format (no long_hair, brown_eyes, etc.)
|
||
- Use readable text: "long flowing hair", "brown eyes", "red dress"
|
||
- Aim for 150-200 words of rich descriptive detail
|
||
- Example: score_9, score_8_up, score_7_up, a young woman with long flowing hair, intense gaze, wearing elegant red dress, soft studio lighting, professional portrait
|
||
"""
|
||
|
||
elif "illustrious" in platform_name.lower():
|
||
prompt += """
|
||
ILLUSTRIOUS-SPECIFIC INSTRUCTIONS:
|
||
- Danbooru tags written with SPACES, not underscores, comma separated
|
||
- Order: subject count, character, general tags, artist, then quality tags LAST: masterpiece, best quality
|
||
- Detailed character appearance tags
|
||
- Clothing and accessory tags
|
||
- Background and atmosphere tags
|
||
- Example: 1girl, solo, long hair, flowing dress, looking at viewer, beach, sunset, masterpiece, best quality
|
||
"""
|
||
|
||
elif "flux" in platform_name.lower():
|
||
prompt += """
|
||
FLUX-SPECIFIC INSTRUCTIONS:
|
||
- Natural language descriptions (75-150 tokens)
|
||
- Use artistic terminology
|
||
- Can include "in the style of [artist/style]"
|
||
- Quality modifiers important
|
||
- Photography terms work well
|
||
"""
|
||
|
||
elif "chroma" in platform_name.lower() or "meisson" in platform_name.lower():
|
||
prompt += """
|
||
CHROMA-SPECIFIC INSTRUCTIONS:
|
||
- Natural, detailed language (100-200 tokens)
|
||
- Excellent with complex scenes
|
||
- Can handle multiple subjects
|
||
- Describe spatial relationships clearly
|
||
- Compositional details important
|
||
"""
|
||
|
||
elif "wan" in platform_name.lower():
|
||
prompt += """
|
||
WAN-SPECIFIC INSTRUCTIONS:
|
||
- Technical cinematography terms
|
||
- Structured format: subject, setting, lighting, composition
|
||
- Specific lighting types (soft lighting, edge lighting, etc.)
|
||
- Professional photography language
|
||
- Medium length (60-120 tokens)
|
||
"""
|
||
|
||
elif "sd_xl" in platform_name.lower() or "sdxl" in platform_name.lower():
|
||
prompt += """
|
||
SDXL-SPECIFIC INSTRUCTIONS:
|
||
- Token limit: 40-75 tokens optimal
|
||
- Front-load important concepts
|
||
- Quality tokens at start
|
||
- Can use natural language or tags
|
||
- Keep concise but descriptive
|
||
"""
|
||
|
||
# The example must match the platform: quality tokens only where the model was trained on them.
|
||
if platform_config.get("prompt_style") == "edit_focused":
|
||
correct_example = (
|
||
"Change the dress to deep red silk while keeping her face, pose, the lighting and the "
|
||
"background exactly the same."
|
||
)
|
||
elif quality_emphasis:
|
||
correct_example = (
|
||
"masterpiece, best quality, detailed portrait of a woman with flowing hair, "
|
||
"golden hour sunlight, shot from low angle, dramatic composition..."
|
||
)
|
||
else:
|
||
correct_example = (
|
||
"A woman with wind-tossed auburn hair stands at the edge of a wheat field, low golden-hour sun "
|
||
"behind her left shoulder rimming her profile, photographed from a low angle on an 85mm lens..."
|
||
)
|
||
|
||
prompt += f"""
|
||
CRITICAL OUTPUT REQUIREMENTS:
|
||
- Output ONLY the final image prompt text
|
||
- DO NOT include any settings information in your output
|
||
- DO NOT append "Settings:" or list the camera/lighting/etc values
|
||
- Incorporate settings naturally INTO the description
|
||
- Follow platform format exactly
|
||
- Use appropriate length for platform
|
||
- Start generating NOW (no preamble, no explanations)
|
||
|
||
Example of CORRECT output:
|
||
"{correct_example}"
|
||
|
||
Example of WRONG output (DO NOT DO THIS):
|
||
"...flowing hair in golden hour sunlight | Settings: camera angle: low angle..."
|
||
"""
|
||
|
||
return prompt
|
||
|
||
def _build_user_prompt(
|
||
self,
|
||
text_prompt: str,
|
||
settings: Dict,
|
||
platform: str,
|
||
prompt_context: str,
|
||
reference_guidance: str,
|
||
creative_brainstorm: Optional[str],
|
||
reference_captions: Optional[List[Tuple[str, str]]] = None
|
||
) -> str:
|
||
"""Build user prompt for LLM"""
|
||
|
||
context_labels = {
|
||
"expand_short_prompt": "Compressed idea to expand",
|
||
"finish_opening_line": "Prompt opening line",
|
||
"prompt_from_item_list": "Checklist of required elements",
|
||
"modify_reference_image": "Modify references",
|
||
"enhance_reference_image": "Expand upon references",
|
||
# Legacy mappings for backward compatibility
|
||
"abbreviated_prompt": "Compressed idea to expand",
|
||
"prompt_seed": "Prompt opening line",
|
||
"item_list": "Checklist of required elements",
|
||
"reference_modification": "Modify references",
|
||
"reference_expansion": "Expand upon references"
|
||
}
|
||
|
||
header = context_labels.get(prompt_context, "Base prompt")
|
||
parts = [f"{header}: {text_prompt}"]
|
||
|
||
parts.append(
|
||
f'MANDATORY SUBJECT ANCHOR: The final description must clearly feature "{text_prompt}" without omission or substitution.'
|
||
)
|
||
|
||
if reference_guidance:
|
||
parts.append(
|
||
"REFERENCE DIRECTIVES (MANDATORY):\n"
|
||
f"{reference_guidance}\n\n"
|
||
"MANDATORY ACTION: integrate each reference block and focus note above into the final prompt exactly once,"
|
||
" preserving every major subject and environmental clue from the captions."
|
||
)
|
||
|
||
if reference_captions:
|
||
caption_lines = [
|
||
"REFERENCE CAPTIONS (VERBATIM):"
|
||
]
|
||
for label, caption in reference_captions:
|
||
caption_lines.append(f"{label}: {caption}")
|
||
caption_lines.append(
|
||
"MANDATORY: Maintain the core subjects, actions, objects, and environmental details expressed in these captions."
|
||
)
|
||
parts.append("\n".join(caption_lines))
|
||
|
||
# Add explicit settings inline - NOT appended to final output
|
||
explicit_settings = []
|
||
for key, value in settings.items():
|
||
if not isinstance(value, str):
|
||
continue
|
||
lowered = value.lower()
|
||
if "auto" in lowered or "none" in lowered:
|
||
continue
|
||
if key in {"prompt_context", "length_mode", "detail_mode"}:
|
||
continue
|
||
display_key = key.replace('_', ' ')
|
||
explicit_settings.append(f"{display_key}: {value}")
|
||
|
||
if explicit_settings:
|
||
parts.append("Settings to weave into the prompt: " + "; ".join(explicit_settings))
|
||
|
||
if creative_brainstorm:
|
||
parts.append(creative_brainstorm)
|
||
|
||
return "\n\n".join(parts)
|
||
|
||
def _parse_llm_response(self, response: str, platform: str) -> str:
|
||
"""Clean and parse LLM response"""
|
||
|
||
cleaned = response.strip()
|
||
|
||
# Remove common artifacts
|
||
artifacts = [
|
||
"here is the prompt:", "here's the prompt:", "here is:", "here's:",
|
||
"prompt:", "final prompt:", "output:", "result:",
|
||
"generated prompt:", "enhanced prompt:"
|
||
]
|
||
|
||
cleaned_lower = cleaned.lower()
|
||
for artifact in artifacts:
|
||
if cleaned_lower.startswith(artifact):
|
||
cleaned = cleaned[len(artifact):].strip()
|
||
cleaned_lower = cleaned.lower()
|
||
|
||
# Remove markdown code blocks
|
||
if cleaned.startswith("```"):
|
||
lines = cleaned.split('\n')
|
||
if len(lines) > 2:
|
||
cleaned = '\n'.join(lines[1:-1])
|
||
cleaned = cleaned.strip()
|
||
|
||
# Remove quotes if entire prompt is quoted
|
||
if cleaned.startswith('"') and cleaned.endswith('"'):
|
||
cleaned = cleaned[1:-1]
|
||
if cleaned.startswith("'") and cleaned.endswith("'"):
|
||
cleaned = cleaned[1:-1]
|
||
|
||
# Remove any "| Settings:" or similar trailing settings info
|
||
import re
|
||
# Pattern to match | Settings: ... or | settings: ... at the end
|
||
cleaned = re.sub(r'\s*\|\s*[Ss]ettings:.*$', '', cleaned)
|
||
# Also catch standalone Settings: at the end
|
||
cleaned = re.sub(r'\s*[Ss]ettings:.*$', '', cleaned)
|
||
# Remove any trailing pipe symbols
|
||
cleaned = cleaned.rstrip('|').strip()
|
||
|
||
return cleaned.strip()
|
||
|
||
def _add_keywords(self, prompt: str, keywords: List[str], platform: str) -> str:
|
||
"""Add custom keywords in platform-appropriate format"""
|
||
|
||
if not keywords:
|
||
return prompt
|
||
|
||
# Check which keywords are already present
|
||
prompt_lower = prompt.lower()
|
||
missing_keywords = [kw for kw in keywords if kw.lower() not in prompt_lower]
|
||
|
||
if not missing_keywords:
|
||
return prompt
|
||
|
||
# Add missing keywords
|
||
platform_config = get_platform_config(platform)
|
||
|
||
if platform in ["illustrious"]:
|
||
# Illustrious tags are written with SPACES (Onoma's paper and model card: "looking at viewer",
|
||
# "hatsune miku"). This used to convert spaces to underscores, which the model was not trained on.
|
||
return f"{prompt}, {', '.join(missing_keywords)}"
|
||
else:
|
||
# Natural language (including Pony after score tags)
|
||
return f"{prompt}, {', '.join(missing_keywords)}"
|
||
|
||
def _add_platform_requirements(self, prompt: str, platform: str) -> str:
|
||
"""Add platform-specific required tokens if missing"""
|
||
|
||
platform_config = get_platform_config(platform)
|
||
required = platform_config.get("required_positive", [])
|
||
|
||
if not required:
|
||
return prompt
|
||
|
||
# Check if required tokens are present
|
||
prompt_lower = prompt.lower()
|
||
missing = [token for token in required if token.lower() not in prompt_lower]
|
||
|
||
if missing:
|
||
# A profile can ask for its quality tags at the end (Illustrious was trained with them there)
|
||
if platform_config.get("required_position") == "end":
|
||
return f"{prompt.rstrip().rstrip(',')}, {', '.join(missing)}"
|
||
# Add at the beginning for these platforms
|
||
if platform in ["pony", "illustrious"]:
|
||
return f"{', '.join(missing)}, {prompt}"
|
||
|
||
return prompt
|
||
|
||
def _format_choice_label(self, text: Optional[str]) -> str:
|
||
"""Human-friendly capitalization for option labels."""
|
||
if not text:
|
||
return ""
|
||
|
||
cleaned = text.replace('_', ' ').strip()
|
||
if not cleaned:
|
||
return ""
|
||
|
||
words = []
|
||
for word in cleaned.split():
|
||
upper = word.upper()
|
||
if upper in {"POV", "OTS", "HDR", "SDXL"}:
|
||
words.append(upper)
|
||
elif any(char.isdigit() for char in word):
|
||
words.append(word.upper())
|
||
else:
|
||
words.append(word.capitalize())
|
||
|
||
result = " ".join(words)
|
||
result = result.replace("'S", "'s").replace("’S", "’s")
|
||
result = result.replace(" - ", "-")
|
||
return result
|
||
|
||
def _format_setting_value(
|
||
self,
|
||
key: str,
|
||
settings: Dict[str, str],
|
||
sources: Dict[str, Dict[str, str]],
|
||
actuals: Dict[str, str]
|
||
) -> Optional[str]:
|
||
"""Produce a descriptive string for a setting using origin metadata."""
|
||
|
||
source = sources.get(key, {})
|
||
mode = source.get("mode")
|
||
stored_value = source.get("value") or settings.get(key)
|
||
actual_value = actuals.get(key)
|
||
|
||
if mode == "llm":
|
||
if actual_value:
|
||
return f"LLM chose {self._format_choice_label(actual_value)}"
|
||
return "LLM will decide during prompt generation"
|
||
|
||
if mode == "random":
|
||
label = self._format_choice_label(stored_value)
|
||
return f"Randomized → {label}" if label else "Randomized"
|
||
|
||
if mode == "auto":
|
||
label = self._format_choice_label(stored_value)
|
||
return f"Auto default → {label}" if label else "Auto-resolved"
|
||
|
||
if mode == "platform":
|
||
return f"{stored_value} (platform default)"
|
||
|
||
if mode == "none":
|
||
return "Not specified"
|
||
|
||
if stored_value:
|
||
return self._format_choice_label(stored_value)
|
||
|
||
if actual_value:
|
||
return self._format_choice_label(actual_value)
|
||
|
||
return None
|
||
|
||
def _infer_setting_mentions(self, prompt: str) -> Dict[str, str]:
|
||
"""Scan the enhanced prompt to detect explicit mentions of key settings."""
|
||
|
||
normalized = prompt.lower().replace("’", "'")
|
||
normalized_alt = normalized.replace('-', ' ')
|
||
search_texts = [normalized, normalized_alt]
|
||
|
||
results: Dict[str, str] = {}
|
||
|
||
search_map: Dict[str, List[str]] = {
|
||
"camera_angle": self.camera_angles,
|
||
"composition": self.composition_styles,
|
||
"lighting_source": self.lighting_sources,
|
||
"lighting_quality": self.lighting_quality,
|
||
"time_of_day": self.times_of_day,
|
||
"weather": self.weather_conditions,
|
||
"art_style": [style for style in [
|
||
"photorealistic", "digital art", "oil painting", "watercolor",
|
||
"anime", "manga", "sketch", "pencil drawing", "3d render",
|
||
"illustration", "concept art", "impressionist", "abstract",
|
||
"pixel art", "low poly", "papercraft", "isometric"
|
||
]],
|
||
"genre_style": self.genre_styles,
|
||
"color_mood": [
|
||
"vibrant", "muted", "monochrome", "warm tones", "cool tones",
|
||
"pastel", "high contrast", "desaturated", "neon", "earth tones"
|
||
],
|
||
"subject_framing": self.subject_framings,
|
||
"subject_pose": self.subject_poses
|
||
}
|
||
|
||
alias_map: Dict[Tuple[str, str], List[str]] = {
|
||
("camera_angle", "point of view"): ["pov", "pov shot", "point-of-view"],
|
||
("camera_angle", "over the shoulder"): ["ots", "over-the-shoulder"],
|
||
("camera_angle", "bird's eye view"): ["birds eye view", "birdseye view", "bird-eye view"],
|
||
("camera_angle", "worm's eye view"): ["worms eye view", "worms-eye view"],
|
||
("subject_framing", "extreme close-up"): ["extreme closeup"],
|
||
("subject_framing", "medium close-up"): ["medium closeup"],
|
||
("subject_pose", "lying down"): ["lying-down"],
|
||
("color_mood", "warm tones"): ["warm-toned", "warm lighting"],
|
||
("color_mood", "cool tones"): ["cool-toned", "cool lighting"],
|
||
("color_mood", "high contrast"): ["high-contrast"],
|
||
("art_style", "3d render"): ["3d-render", "3d rendering"]
|
||
}
|
||
|
||
for key, options in search_map.items():
|
||
if key in results:
|
||
continue
|
||
for option in options:
|
||
option_lower = option.lower()
|
||
if any(option_lower in text for text in search_texts):
|
||
results[key] = option
|
||
break
|
||
alias_key = (key, option_lower)
|
||
aliases = alias_map.get(alias_key, [])
|
||
if any(alias.lower() in text for text in search_texts for alias in aliases):
|
||
results[key] = option
|
||
break
|
||
|
||
return results
|
||
|
||
def _format_settings_display(
|
||
self,
|
||
settings: Dict[str, str],
|
||
platform_config: Dict[str, Any],
|
||
reference_notes: List[str],
|
||
context: Dict[str, Any]
|
||
) -> str:
|
||
"""Format settings for display with origin tracking and detected selections."""
|
||
|
||
sources: Dict[str, Dict[str, str]] = context.get("setting_sources", {}) or {}
|
||
actuals: Dict[str, str] = context.get("actual_selections", {}) or {}
|
||
ref_meta: Dict[str, Any] = context.get("reference_meta", {}) or {}
|
||
main_llm_success: bool = context.get("main_llm_success", True)
|
||
main_llm_error: str = context.get("main_llm_error", "")
|
||
ref_count: int = context.get("reference_image_count", 0)
|
||
quality_emphasis_active: bool = bool(context.get("quality_emphasis", False))
|
||
ref_warnings: List[str] = context.get("reference_warnings", []) or []
|
||
vision_context: Dict[str, Any] = context.get("vision", {}) or {}
|
||
|
||
lines = [
|
||
"=" * 60,
|
||
"TEXT-TO-IMAGE PROMPT ENHANCEMENT",
|
||
"=" * 60,
|
||
f"\nTarget Platform: {platform_config['name']}",
|
||
f"Prompting Style: {platform_config['prompt_style']}",
|
||
f"Optimal Length: {platform_config['optimal_length']}"
|
||
]
|
||
|
||
lines.append("\nLLM CALL SUMMARY:")
|
||
if main_llm_success:
|
||
lines.append(" - Main prompt LLM: responded")
|
||
else:
|
||
snippet = (main_llm_error or "unknown error").splitlines()[0][:120]
|
||
snippet = snippet.replace('|', '/').strip()
|
||
lines.append(f" - Main prompt LLM: FAILED ({snippet or 'no details'})")
|
||
|
||
analysis_method = ref_meta.get("analysis_method", "none")
|
||
ref_queries = ref_meta.get("llm_queries", 0)
|
||
ref_successes = ref_meta.get("llm_successes", 0)
|
||
if ref_count:
|
||
if ref_queries:
|
||
lines.append(
|
||
f" - Reference LLM: {ref_successes}/{ref_queries} responses ({analysis_method})"
|
||
)
|
||
else:
|
||
lines.append(f" - Reference LLM: not invoked ({analysis_method})")
|
||
else:
|
||
lines.append(" - Reference LLM: no reference images")
|
||
|
||
lines.append(f" - References sent: {ref_count}")
|
||
if ref_count:
|
||
lines.append(f" - Reference guidance applied: {'yes' if context.get('reference_guidance_used') else 'no'}")
|
||
directive_labels = ref_meta.get("directive_labels") or []
|
||
if directive_labels:
|
||
lines.append(f" - Reference directives: {', '.join(directive_labels)}")
|
||
lines.append(f" - Quality emphasis: {'enabled' if quality_emphasis_active else 'disabled'}")
|
||
|
||
if vision_context:
|
||
requested_backend = (vision_context.get("requested_backend") or "auto").strip() or "auto"
|
||
resolved_backend = (vision_context.get("resolved_backend") or "disabled").strip() or "disabled"
|
||
resolved_model = vision_context.get("resolved_model") or "auto"
|
||
caption_enabled = bool(vision_context.get("caption_enabled"))
|
||
|
||
if resolved_backend.startswith("inherit:"):
|
||
inherit_source = resolved_backend.split(":", 1)[1]
|
||
resolved_display = f"inherit ({inherit_source})"
|
||
else:
|
||
resolved_display = resolved_backend
|
||
|
||
status_note = "enabled" if caption_enabled else "disabled"
|
||
lines.append(
|
||
f" - Vision captioning: {status_note} (requested: {requested_backend}, resolved: {resolved_display})"
|
||
)
|
||
if caption_enabled and resolved_model:
|
||
lines.append(f" • Vision model: {resolved_model}")
|
||
|
||
lines.append("\nSETTINGS APPLIED:")
|
||
|
||
context_display = {
|
||
"expand_short_prompt": "Abbreviated prompt (expand fully)",
|
||
"finish_opening_line": "Prompt seed (continue logically)",
|
||
"prompt_from_item_list": "Item list (merge into scene)",
|
||
"modify_reference_image": "Modify reference traits",
|
||
"enhance_reference_image": "Expand reference storytelling",
|
||
# Legacy mappings for backward compatibility
|
||
"abbreviated_prompt": "Abbreviated prompt (expand fully)",
|
||
"prompt_seed": "Prompt seed (continue logically)",
|
||
"item_list": "Item list (merge into scene)",
|
||
"reference_modification": "Modify reference traits",
|
||
"reference_expansion": "Expand reference storytelling"
|
||
}
|
||
|
||
ordered_keys = [
|
||
"camera_angle", "composition", "lighting_source", "lighting_quality",
|
||
"time_of_day", "historical_period", "weather",
|
||
"art_style", "genre_style", "color_mood",
|
||
"creative_randomness", "subject_framing", "subject_pose",
|
||
"prompt_context", "length_mode", "detail_mode", "quality_emphasis"
|
||
]
|
||
|
||
remaining_keys = [key for key in settings.keys() if key not in ordered_keys]
|
||
display_keys = ordered_keys + remaining_keys
|
||
|
||
for key in display_keys:
|
||
if key not in settings:
|
||
continue
|
||
|
||
label = key.replace("_", " ").title()
|
||
desc: Optional[str] = None
|
||
|
||
if key == "prompt_context":
|
||
mode = sources.get(key, {}).get("mode")
|
||
if mode == "llm":
|
||
desc = "LLM interprets input role dynamically"
|
||
else:
|
||
raw_value = settings.get(key, "")
|
||
desc = context_display.get(raw_value, self._format_choice_label(raw_value))
|
||
|
||
elif key == "creative_randomness":
|
||
desc = self._format_setting_value(key, settings, sources, actuals)
|
||
raw_value = (settings.get(key, "") or "").lower()
|
||
explanation = self.creative_randomness_modes.get(raw_value)
|
||
if explanation:
|
||
pretty = self._format_choice_label(raw_value)
|
||
if desc:
|
||
desc = f"{desc} — {explanation}"
|
||
else:
|
||
desc = f"{pretty or raw_value} — {explanation}"
|
||
|
||
elif key in {"length_mode", "detail_mode"}:
|
||
desc = self._format_setting_value(key, settings, sources, actuals)
|
||
|
||
elif key == "quality_emphasis":
|
||
mode = sources.get(key, {}).get("mode")
|
||
if mode == "platform":
|
||
origin = "platform default"
|
||
elif mode:
|
||
origin = mode
|
||
else:
|
||
origin = settings.get(key, "")
|
||
desc = f"{'Enabled' if quality_emphasis_active else 'Disabled'} ({origin})"
|
||
|
||
else:
|
||
desc = self._format_setting_value(key, settings, sources, actuals)
|
||
|
||
if not desc:
|
||
desc = settings.get(key)
|
||
|
||
if desc:
|
||
lines.append(f" - {label}: {desc}")
|
||
|
||
if ref_count:
|
||
lines.append("\nREFERENCE IMAGE NOTES:")
|
||
guardrail = ref_meta.get("guardrail")
|
||
if guardrail:
|
||
lines.append(f" • Guardrail: {guardrail}")
|
||
for note in reference_notes:
|
||
lines.append(f" • {note}")
|
||
for warning in ref_warnings:
|
||
lines.append(f" • Warning: {warning}")
|
||
caption_sources = ref_meta.get("vision_caption_sources") or []
|
||
backends_used = ref_meta.get("vision_backends_used") or []
|
||
models_used = ref_meta.get("vision_models_used") or []
|
||
if backends_used:
|
||
lines.append(f" • Vision backends used: {', '.join(backends_used)}")
|
||
if models_used:
|
||
lines.append(f" • Vision models used: {', '.join(model for model in models_used if model) or 'n/a'}")
|
||
if caption_sources:
|
||
lines.append(f" • Vision caption sources: {', '.join(caption_sources)}")
|
||
elif ref_warnings:
|
||
lines.append("\nREFERENCE WARNINGS:")
|
||
for warning in ref_warnings:
|
||
lines.append(f" • {warning}")
|
||
|
||
seed_info = context.get("random_seed") or {}
|
||
seed_value = seed_info.get("value")
|
||
if seed_value is not None:
|
||
mode_label = seed_info.get("mode", "manual")
|
||
lines.append(f"\nRandom Seed Used: {seed_value} ({mode_label})")
|
||
|
||
lines.append("\n" + "=" * 60)
|
||
|
||
return "\n".join(lines)
|