Updated generation pages.

This commit is contained in:
Aodhan Collins
2026-03-15 17:45:17 +00:00
parent 79bbf669e2
commit d756ea1d0e
30 changed files with 2033 additions and 189 deletions

164
migrate_field_groups.py Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Migrate character, outfit, and action JSON files to new field groupings.
New groups combine identity + wardrobe fields by body region:
base - base_specs / full_body → all detailers
head - hair+eyes / headwear → face detailer
upper_body - arms+torso / top
lower_body - pelvis+legs / bottom+legwear
hands - hands / hands+gloves → hand detailer
feet - feet / footwear → foot detailer
additional - extra / accessories
"""
import json
import glob
import os
DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
def merge(*values):
"""Concatenate non-empty strings with ', '."""
parts = [v.strip().rstrip(',').strip() for v in values if v and v.strip()]
return ', '.join(parts)
def migrate_identity(identity):
"""Convert old identity dict to new grouped format."""
return {
'base': identity.get('base_specs', ''),
'head': merge(identity.get('hair', ''), identity.get('eyes', '')),
'upper_body': merge(identity.get('arms', ''), identity.get('torso', '')),
'lower_body': merge(identity.get('pelvis', ''), identity.get('legs', '')),
'hands': identity.get('hands', ''),
'feet': identity.get('feet', ''),
'additional': identity.get('extra', ''),
}
def migrate_wardrobe_outfit(outfit):
"""Convert old wardrobe outfit dict to new grouped format."""
return {
'base': outfit.get('full_body', ''),
'head': outfit.get('headwear', ''),
'upper_body': outfit.get('top', ''),
'lower_body': merge(outfit.get('bottom', ''), outfit.get('legwear', '')),
'hands': merge(outfit.get('hands', ''), outfit.get('gloves', '')),
'feet': outfit.get('footwear', ''),
'additional': outfit.get('accessories', ''),
}
def migrate_action(action):
"""Convert old action dict to new grouped format."""
return {
'base': action.get('full_body', ''),
'head': merge(action.get('head', ''), action.get('eyes', '')),
'upper_body': merge(action.get('arms', ''), action.get('torso', '')),
'lower_body': merge(action.get('pelvis', ''), action.get('legs', '')),
'hands': action.get('hands', ''),
'feet': action.get('feet', ''),
'additional': action.get('additional', ''),
}
def process_file(path, transform_fn, section_key):
"""Load JSON, apply transform to a section, write back."""
with open(path, 'r') as f:
data = json.load(f)
section = data.get(section_key)
if section is None:
print(f" SKIP {path} — no '{section_key}' section")
return False
# Check if already migrated (new keys present, no old keys)
if 'upper_body' in section and 'base' in section and 'full_body' not in section and 'base_specs' not in section:
print(f" SKIP {path} — already migrated")
return False
data[section_key] = transform_fn(section)
with open(path, 'w') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
return True
def process_character(path):
"""Migrate a character JSON file (identity + all wardrobe outfits)."""
with open(path, 'r') as f:
data = json.load(f)
changed = False
# Migrate identity
identity = data.get('identity', {})
if identity and 'base_specs' in identity:
data['identity'] = migrate_identity(identity)
changed = True
# Migrate wardrobe (nested format with named outfits)
wardrobe = data.get('wardrobe', {})
if wardrobe:
new_wardrobe = {}
for outfit_name, outfit_data in wardrobe.items():
if isinstance(outfit_data, dict):
if 'full_body' in outfit_data or 'headwear' in outfit_data or 'top' in outfit_data:
new_wardrobe[outfit_name] = migrate_wardrobe_outfit(outfit_data)
changed = True
else:
new_wardrobe[outfit_name] = outfit_data # already migrated or unknown format
else:
new_wardrobe[outfit_name] = outfit_data
data['wardrobe'] = new_wardrobe
if changed:
with open(path, 'w') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
return changed
def main():
stats = {'characters': 0, 'outfits': 0, 'actions': 0, 'skipped': 0}
# Characters
print("=== Characters ===")
for path in sorted(glob.glob(os.path.join(DATA_DIR, 'characters', '*.json'))):
name = os.path.basename(path)
if process_character(path):
print(f" OK {name}")
stats['characters'] += 1
else:
print(f" SKIP {name}")
stats['skipped'] += 1
# Outfits (clothing)
print("\n=== Outfits ===")
for path in sorted(glob.glob(os.path.join(DATA_DIR, 'clothing', '*.json'))):
name = os.path.basename(path)
if process_file(path, migrate_wardrobe_outfit, 'wardrobe'):
print(f" OK {name}")
stats['outfits'] += 1
else:
print(f" SKIP {name}")
stats['skipped'] += 1
# Actions
print("\n=== Actions ===")
for path in sorted(glob.glob(os.path.join(DATA_DIR, 'actions', '*.json'))):
name = os.path.basename(path)
if process_file(path, migrate_action, 'action'):
print(f" OK {name}")
stats['actions'] += 1
else:
print(f" SKIP {name}")
stats['skipped'] += 1
print(f"\nDone! Characters: {stats['characters']}, Outfits: {stats['outfits']}, "
f"Actions: {stats['actions']}, Skipped: {stats['skipped']}")
if __name__ == '__main__':
main()

View File

@@ -12,6 +12,8 @@ def register_routes(app):
from routes import looks from routes import looks
from routes import presets from routes import presets
from routes import generator from routes import generator
from routes import quick
from routes import multi_char
from routes import gallery from routes import gallery
from routes import strengths from routes import strengths
from routes import transfer from routes import transfer
@@ -28,6 +30,8 @@ def register_routes(app):
looks.register_routes(app) looks.register_routes(app)
presets.register_routes(app) presets.register_routes(app)
generator.register_routes(app) generator.register_routes(app)
quick.register_routes(app)
multi_char.register_routes(app)
gallery.register_routes(app) gallery.register_routes(app)
strengths.register_routes(app) strengths.register_routes(app)
transfer.register_routes(app) transfer.register_routes(app)

View File

@@ -213,11 +213,12 @@ def register_routes(app):
combined_data['participants'] = action_obj.data.get('participants', {}) # Add participants combined_data['participants'] = action_obj.data.get('participants', {}) # Add participants
# Aggregate pose-related fields into 'pose' # Aggregate pose-related fields into 'pose'
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet'] pose_fields = ['base', 'upper_body', 'lower_body', 'hands', 'feet']
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)] pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
# Aggregate expression-related fields into 'expression' # Aggregate expression-related fields into 'expression'
expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)] expression_parts = [action_data.get('head', '')]
expression_parts = [p for p in expression_parts if p]
combined_data['defaults'] = { combined_data['defaults'] = {
'pose': ", ".join(pose_parts), 'pose': ", ".join(pose_parts),
@@ -245,12 +246,13 @@ def register_routes(app):
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults) # Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression'] selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
# Add identity fields # Add identity fields
for key in ['base_specs', 'hair', 'eyes']: for key in ['base', 'head']:
if character.data.get('identity', {}).get(key): if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}') selected_fields.append(f'identity::{key}')
# Add wardrobe fields # Add wardrobe fields
from utils import _WARDROBE_KEYS
wardrobe = character.get_active_wardrobe() wardrobe = character.get_active_wardrobe()
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: for key in _WARDROBE_KEYS:
if wardrobe.get(key): if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}') selected_fields.append(f'wardrobe::{key}')
@@ -261,11 +263,12 @@ def register_routes(app):
action_data = action_obj.data.get('action', {}) action_data = action_obj.data.get('action', {})
# Aggregate pose-related fields into 'pose' # Aggregate pose-related fields into 'pose'
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet'] pose_fields = ['base', 'upper_body', 'lower_body', 'hands', 'feet']
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)] pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
# Aggregate expression-related fields into 'expression' # Aggregate expression-related fields into 'expression'
expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)] expression_parts = [action_data.get('head', '')]
expression_parts = [p for p in expression_parts if p]
combined_data = { combined_data = {
'character_id': action_obj.action_id, 'character_id': action_obj.action_id,
@@ -312,7 +315,7 @@ def register_routes(app):
# Identity # Identity
ident = extra_char.data.get('identity', {}) ident = extra_char.data.get('identity', {})
for key in ['base_specs', 'hair', 'eyes', 'extra']: for key in ['base', 'head', 'additional']:
val = ident.get(key) val = ident.get(key)
if val: if val:
# Remove 1girl/solo # Remove 1girl/solo
@@ -320,8 +323,9 @@ def register_routes(app):
extra_parts.append(val) extra_parts.append(val)
# Wardrobe (active outfit) # Wardrobe (active outfit)
from utils import _WARDROBE_KEYS
wardrobe = extra_char.get_active_wardrobe() wardrobe = extra_char.get_active_wardrobe()
for key in ['top', 'headwear', 'legwear', 'footwear', 'accessories']: for key in _WARDROBE_KEYS:
val = wardrobe.get(key) val = wardrobe.get(key)
if val: if val:
extra_parts.append(val) extra_parts.append(val)
@@ -531,8 +535,8 @@ def register_routes(app):
"action_id": safe_slug, "action_id": safe_slug,
"action_name": name, "action_name": name,
"action": { "action": {
"full_body": "", "head": "", "eyes": "", "arms": "", "hands": "", "base": "", "head": "", "upper_body": "", "lower_body": "",
"torso": "", "pelvis": "", "legs": "", "feet": "", "additional": "" "hands": "", "feet": "", "additional": ""
}, },
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""}, "lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
"tags": [] "tags": []

View File

@@ -295,14 +295,13 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
# Ensure required fields # Ensure required fields
if 'wardrobe' not in outfit_data: if 'wardrobe' not in outfit_data:
outfit_data['wardrobe'] = { outfit_data['wardrobe'] = {
"full_body": "", "base": "",
"headwear": "", "head": "",
"top": "", "upper_body": "",
"bottom": "", "lower_body": "",
"legwear": "",
"footwear": "",
"hands": "", "hands": "",
"accessories": "" "feet": "",
"additional": ""
} }
if 'lora' not in outfit_data: if 'lora' not in outfit_data:
outfit_data['lora'] = { outfit_data['lora'] = {
@@ -392,16 +391,13 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
"character_id": safe_slug, "character_id": safe_slug,
"character_name": name, "character_name": name,
"identity": { "identity": {
"base_specs": prompt, "base": prompt,
"hair": "", "head": "",
"eyes": "", "upper_body": "",
"lower_body": "",
"hands": "", "hands": "",
"arms": "",
"torso": "",
"pelvis": "",
"legs": "",
"feet": "", "feet": "",
"extra": "" "additional": ""
}, },
"defaults": { "defaults": {
"expression": "", "expression": "",
@@ -631,8 +627,8 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
# Create new outfit (copy from default as template) # Create new outfit (copy from default as template)
default_outfit = wardrobe.get('default', { default_outfit = wardrobe.get('default', {
'headwear': '', 'top': '', 'legwear': '', 'base': '', 'head': '', 'upper_body': '', 'lower_body': '',
'footwear': '', 'hands': '', 'accessories': '' 'hands': '', 'feet': '', 'additional': ''
}) })
wardrobe[safe_name] = default_outfit.copy() wardrobe[safe_name] = default_outfit.copy()

View File

@@ -31,12 +31,12 @@ def register_routes(app):
combined_data = character.data.copy() combined_data = character.data.copy()
combined_data['character_id'] = character.character_id combined_data['character_id'] = character.character_id
selected_fields = [] selected_fields = []
for key in ['base_specs', 'hair', 'eyes']: for key in ['base', 'head']:
if character.data.get('identity', {}).get(key): if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}') selected_fields.append(f'identity::{key}')
selected_fields.append('special::name') selected_fields.append('special::name')
wardrobe = character.get_active_wardrobe() wardrobe = character.get_active_wardrobe()
for key in ['full_body', 'top', 'bottom']: for key in ['base', 'upper_body', 'lower_body']:
if wardrobe.get(key): if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}') selected_fields.append(f'wardrobe::{key}')
prompts = build_prompt(combined_data, selected_fields, None, active_outfit=character.active_outfit) prompts = build_prompt(combined_data, selected_fields, None, active_outfit=character.active_outfit)

View File

@@ -45,7 +45,7 @@ def register_routes(app):
else: else:
# Auto-include essential character fields (minimal set for batch/default generation) # Auto-include essential character fields (minimal set for batch/default generation)
selected_fields = [] selected_fields = []
for key in ['base_specs', 'hair', 'eyes']: for key in ['base', 'head']:
if character.data.get('identity', {}).get(key): if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}') selected_fields.append(f'identity::{key}')
selected_fields.append('special::name') selected_fields.append('special::name')

View File

@@ -6,6 +6,7 @@ from services.prompts import build_prompt, build_extras_prompt
from services.workflow import _prepare_workflow, _get_default_checkpoint from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize
from services.file_io import get_available_checkpoints from services.file_io import get_available_checkpoints
from services.comfyui import get_loaded_checkpoint
logger = logging.getLogger('gaze') logger = logging.getLogger('gaze')
@@ -25,6 +26,12 @@ def register_routes(app):
if not checkpoints: if not checkpoints:
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"] checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
# Default to whatever is currently loaded in ComfyUI, then settings default
selected_ckpt = get_loaded_checkpoint()
if not selected_ckpt:
default_path, _ = _get_default_checkpoint()
selected_ckpt = default_path
if request.method == 'POST': if request.method == 'POST':
char_slug = request.form.get('character') char_slug = request.form.get('character')
checkpoint = request.form.get('checkpoint') checkpoint = request.form.get('checkpoint')
@@ -63,9 +70,17 @@ def register_routes(app):
if extras: if extras:
combined = f"{combined}, {extras}" combined = f"{combined}, {extras}"
if custom_positive: if custom_positive:
combined = f"{combined}, {custom_positive}" combined = f"{custom_positive}, {combined}"
prompts["main"] = combined prompts["main"] = combined
# Apply face/hand prompt overrides if provided
override_face = request.form.get('override_face_prompt', '').strip()
override_hand = request.form.get('override_hand_prompt', '').strip()
if override_face:
prompts["face"] = override_face
if override_hand:
prompts["hand"] = override_hand
# Parse optional seed # Parse optional seed
seed_val = request.form.get('seed', '').strip() seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None fixed_seed = int(seed_val) if seed_val else None
@@ -103,7 +118,7 @@ def register_routes(app):
return render_template('generator.html', characters=characters, checkpoints=checkpoints, return render_template('generator.html', characters=characters, checkpoints=checkpoints,
actions=actions, outfits=outfits, scenes=scenes, actions=actions, outfits=outfits, scenes=scenes,
styles=styles, detailers=detailers) styles=styles, detailers=detailers, selected_ckpt=selected_ckpt)
@app.route('/generator/preview_prompt', methods=['POST']) @app.route('/generator/preview_prompt', methods=['POST'])
def generator_preview_prompt(): def generator_preview_prompt():
@@ -134,6 +149,6 @@ def register_routes(app):
if extras: if extras:
combined = f"{combined}, {extras}" combined = f"{combined}, {extras}"
if custom_positive: if custom_positive:
combined = f"{combined}, {custom_positive}" combined = f"{custom_positive}, {combined}"
return {'prompt': combined} return {'prompt': combined, 'face': prompts['face'], 'hand': prompts['hand']}

View File

@@ -356,16 +356,13 @@ Character ID: {character_slug}"""
"character_id": character_slug, "character_id": character_slug,
"character_name": character_name, "character_name": character_name,
"identity": { "identity": {
"base_specs": lora_data.get('lora_triggers', ''), "base": lora_data.get('lora_triggers', ''),
"hair": "", "head": "",
"eyes": "", "upper_body": "",
"lower_body": "",
"hands": "", "hands": "",
"arms": "",
"torso": "",
"pelvis": "",
"legs": "",
"feet": "", "feet": "",
"extra": "" "additional": ""
}, },
"defaults": { "defaults": {
"expression": "", "expression": "",
@@ -373,14 +370,13 @@ Character ID: {character_slug}"""
"scene": "" "scene": ""
}, },
"wardrobe": { "wardrobe": {
"full_body": "", "base": "",
"headwear": "", "head": "",
"top": "", "upper_body": "",
"bottom": "", "lower_body": "",
"legwear": "",
"footwear": "",
"hands": "", "hands": "",
"accessories": "" "feet": "",
"additional": ""
}, },
"styles": { "styles": {
"aesthetic": "", "aesthetic": "",

153
routes/multi_char.py Normal file
View File

@@ -0,0 +1,153 @@
import json
import logging
from flask import render_template, request, session
from models import Character, Action, Style, Scene, Detailer, Checkpoint
from services.prompts import build_multi_prompt, build_extras_prompt, _resolve_character
from services.workflow import _prepare_workflow
from services.job_queue import _enqueue_job, _make_finalize
from services.file_io import get_available_checkpoints
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/multi', methods=['GET', 'POST'])
def multi_char():
characters = Character.query.order_by(Character.name).all()
checkpoints = get_available_checkpoints()
actions = Action.query.order_by(Action.name).all()
scenes = Scene.query.order_by(Scene.name).all()
styles = Style.query.order_by(Style.name).all()
detailers = Detailer.query.order_by(Detailer.name).all()
if not checkpoints:
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
if request.method == 'POST':
char_a_slug = request.form.get('char_a')
char_b_slug = request.form.get('char_b')
checkpoint = request.form.get('checkpoint')
custom_positive = request.form.get('positive_prompt', '')
custom_negative = request.form.get('negative_prompt', '')
action_slugs = request.form.getlist('action_slugs')
scene_slugs = request.form.getlist('scene_slugs')
style_slugs = request.form.getlist('style_slugs')
detailer_slugs = request.form.getlist('detailer_slugs')
width = request.form.get('width') or 1024
height = request.form.get('height') or 1024
char_a = _resolve_character(char_a_slug)
char_b = _resolve_character(char_b_slug)
if not char_a or not char_b:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Both characters must be selected'}, 400
return render_template('multi_char.html', characters=characters, checkpoints=checkpoints,
actions=actions, scenes=scenes, styles=styles, detailers=detailers)
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
try:
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
# Build extras from mix-and-match selections
extras = build_extras_prompt(sel_actions, [], sel_scenes, sel_styles, sel_detailers)
if custom_positive:
extras = f"{custom_positive}, {extras}" if extras else custom_positive
# Build multi-character prompt
prompts = build_multi_prompt(char_a, char_b, extras_prompt=extras)
# Apply prompt overrides if provided
override_main = request.form.get('override_prompt', '').strip()
if override_main:
prompts['main'] = override_main
for key in ('char_a_main', 'char_b_main', 'char_a_face', 'char_b_face'):
override = request.form.get(f'override_{key}', '').strip()
if override:
prompts[key] = override
# Parse optional seed
seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None
# Prepare workflow with both character LoRAs
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint).first() if checkpoint else None
workflow = _prepare_workflow(
workflow, char_a, prompts, checkpoint, custom_negative,
action=sel_actions[0] if sel_actions else None,
style=sel_styles[0] if sel_styles else None,
detailer=sel_detailers[0] if sel_detailers else None,
scene=sel_scenes[0] if sel_scenes else None,
width=width,
height=height,
checkpoint_data=ckpt_obj.data if ckpt_obj else None,
fixed_seed=fixed_seed,
character_b=char_b,
)
label = f"Multi: {char_a.name} + {char_b.name}"
slug_pair = f"{char_a.slug}_{char_b.slug}"
_finalize = _make_finalize('multi', slug_pair)
job = _enqueue_job(label, workflow, _finalize)
# Save to session
session['multi_char_a'] = char_a.slug
session['multi_char_b'] = char_b.slug
session.modified = True
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
except Exception as e:
logger.exception("Multi-char generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
return render_template('multi_char.html', characters=characters, checkpoints=checkpoints,
actions=actions, scenes=scenes, styles=styles, detailers=detailers,
selected_char_a=session.get('multi_char_a', ''),
selected_char_b=session.get('multi_char_b', ''))
@app.route('/multi/preview_prompt', methods=['POST'])
def multi_preview_prompt():
char_a_slug = request.form.get('char_a')
char_b_slug = request.form.get('char_b')
if not char_a_slug or not char_b_slug:
return {'error': 'Both characters must be selected'}, 400
char_a = _resolve_character(char_a_slug)
char_b = _resolve_character(char_b_slug)
if not char_a or not char_b:
return {'error': 'Character not found'}, 404
action_slugs = request.form.getlist('action_slugs')
scene_slugs = request.form.getlist('scene_slugs')
style_slugs = request.form.getlist('style_slugs')
detailer_slugs = request.form.getlist('detailer_slugs')
custom_positive = request.form.get('positive_prompt', '')
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
extras = build_extras_prompt(sel_actions, [], sel_scenes, sel_styles, sel_detailers)
if custom_positive:
extras = f"{custom_positive}, {extras}" if extras else custom_positive
prompts = build_multi_prompt(char_a, char_b, extras_prompt=extras)
return {
'prompt': prompts['main'],
'hand': prompts['hand'],
'char_a_main': prompts['char_a_main'],
'char_a_face': prompts['char_a_face'],
'char_b_main': prompts['char_b_main'],
'char_b_face': prompts['char_b_face'],
}

View File

@@ -336,11 +336,12 @@ def register_routes(app):
# No explicit field selection (e.g. batch generation) — build a selection # No explicit field selection (e.g. batch generation) — build a selection
# that includes identity + wardrobe + name + lora triggers, but NOT character # that includes identity + wardrobe + name + lora triggers, but NOT character
# defaults (expression, pose, scene), so outfit covers stay generic. # defaults (expression, pose, scene), so outfit covers stay generic.
for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: from utils import _IDENTITY_KEYS, _WARDROBE_KEYS
for key in _IDENTITY_KEYS:
if character.data.get('identity', {}).get(key): if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}') selected_fields.append(f'identity::{key}')
outfit_wardrobe = outfit.data.get('wardrobe', {}) outfit_wardrobe = outfit.data.get('wardrobe', {})
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: for key in _WARDROBE_KEYS:
if outfit_wardrobe.get(key): if outfit_wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}') selected_fields.append(f'wardrobe::{key}')
selected_fields.append('special::name') selected_fields.append('special::name')
@@ -456,14 +457,13 @@ def register_routes(app):
# Ensure required fields exist # Ensure required fields exist
if 'wardrobe' not in outfit_data: if 'wardrobe' not in outfit_data:
outfit_data['wardrobe'] = { outfit_data['wardrobe'] = {
"full_body": "", "base": "",
"headwear": "", "head": "",
"top": "", "upper_body": "",
"bottom": "", "lower_body": "",
"legwear": "",
"footwear": "",
"hands": "", "hands": "",
"accessories": "" "feet": "",
"additional": ""
} }
if 'lora' not in outfit_data: if 'lora' not in outfit_data:
outfit_data['lora'] = { outfit_data['lora'] = {
@@ -484,14 +484,13 @@ def register_routes(app):
"outfit_id": safe_slug, "outfit_id": safe_slug,
"outfit_name": name, "outfit_name": name,
"wardrobe": { "wardrobe": {
"full_body": "", "base": "",
"headwear": "", "head": "",
"top": "", "upper_body": "",
"bottom": "", "lower_body": "",
"legwear": "",
"footwear": "",
"hands": "", "hands": "",
"accessories": "" "feet": "",
"additional": ""
}, },
"lora": { "lora": {
"lora_name": "", "lora_name": "",

View File

@@ -70,18 +70,24 @@ def register_routes(app):
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id')) detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id')) look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
# Checkpoint: preset override or session default # Checkpoint: form override > preset config > session default
preset_ckpt = ckpt_cfg.get('checkpoint_path') checkpoint_override = request.form.get('checkpoint_override', '').strip()
if preset_ckpt == 'random': if checkpoint_override:
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first() ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint_override).first()
ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None ckpt_path = checkpoint_override
ckpt_data = ckpt_obj.data if ckpt_obj else None
elif preset_ckpt:
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first()
ckpt_path = preset_ckpt
ckpt_data = ckpt_obj.data if ckpt_obj else None ckpt_data = ckpt_obj.data if ckpt_obj else None
else: else:
ckpt_path, ckpt_data = _get_default_checkpoint() preset_ckpt = ckpt_cfg.get('checkpoint_path')
if preset_ckpt == 'random':
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first()
ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None
ckpt_data = ckpt_obj.data if ckpt_obj else None
elif preset_ckpt:
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first()
ckpt_path = preset_ckpt
ckpt_data = ckpt_obj.data if ckpt_obj else None
else:
ckpt_path, ckpt_data = _get_default_checkpoint()
# Resolve selected fields from preset toggles # Resolve selected fields from preset toggles
selected_fields = _resolve_preset_fields(data) selected_fields = _resolve_preset_fields(data)
@@ -107,7 +113,8 @@ def register_routes(app):
extras_parts = [] extras_parts = []
if action_obj: if action_obj:
action_fields = action_cfg.get('fields', {}) action_fields = action_cfg.get('fields', {})
for key in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']: from utils import _BODY_GROUP_KEYS
for key in _BODY_GROUP_KEYS:
val_cfg = action_fields.get(key, True) val_cfg = action_fields.get(key, True)
if val_cfg == 'random': if val_cfg == 'random':
val_cfg = random.choice([True, False]) val_cfg = random.choice([True, False])
@@ -172,6 +179,23 @@ def register_routes(app):
seed_val = request.form.get('seed', '').strip() seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None fixed_seed = int(seed_val) if seed_val else None
# Resolution: form override > preset config > workflow default
res_cfg = data.get('resolution', {})
form_width = request.form.get('width', '').strip()
form_height = request.form.get('height', '').strip()
if form_width and form_height:
gen_width = int(form_width)
gen_height = int(form_height)
elif res_cfg.get('random', False):
_RES_OPTIONS = [
(1024, 1024), (1152, 896), (896, 1152), (1344, 768),
(768, 1344), (1280, 800), (800, 1280),
]
gen_width, gen_height = random.choice(_RES_OPTIONS)
else:
gen_width = res_cfg.get('width') or None
gen_height = res_cfg.get('height') or None
workflow = _prepare_workflow( workflow = _prepare_workflow(
workflow, character, prompts, workflow, character, prompts,
checkpoint=ckpt_path, checkpoint_data=ckpt_data, checkpoint=ckpt_path, checkpoint_data=ckpt_data,
@@ -183,6 +207,8 @@ def register_routes(app):
detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None, detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None,
look=look_obj, look=look_obj,
fixed_seed=fixed_seed, fixed_seed=fixed_seed,
width=gen_width,
height=gen_height,
) )
label = f"Preset: {preset.name} {action}" label = f"Preset: {preset.name} {action}"
@@ -258,13 +284,13 @@ def register_routes(app):
'use_lora': request.form.get('char_use_lora') == 'on', 'use_lora': request.form.get('char_use_lora') == 'on',
'fields': { 'fields': {
'identity': {k: _tog(request.form.get(f'id_{k}', 'true')) 'identity': {k: _tog(request.form.get(f'id_{k}', 'true'))
for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']}, for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
'defaults': {k: _tog(request.form.get(f'def_{k}', 'false')) 'defaults': {k: _tog(request.form.get(f'def_{k}', 'false'))
for k in ['expression', 'pose', 'scene']}, for k in ['expression', 'pose', 'scene']},
'wardrobe': { 'wardrobe': {
'outfit': request.form.get('wardrobe_outfit', 'default') or 'default', 'outfit': request.form.get('wardrobe_outfit', 'default') or 'default',
'fields': {k: _tog(request.form.get(f'wd_{k}', 'true')) 'fields': {k: _tog(request.form.get(f'wd_{k}', 'true'))
for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}, for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
}, },
}, },
}, },
@@ -273,7 +299,7 @@ def register_routes(app):
'action': {'action_id': _entity_id(request.form.get('action_id')), 'action': {'action_id': _entity_id(request.form.get('action_id')),
'use_lora': request.form.get('action_use_lora') == 'on', 'use_lora': request.form.get('action_use_lora') == 'on',
'fields': {k: _tog(request.form.get(f'act_{k}', 'true')) 'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}}, for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
'style': {'style_id': _entity_id(request.form.get('style_id')), 'style': {'style_id': _entity_id(request.form.get('style_id')),
'use_lora': request.form.get('style_use_lora') == 'on'}, 'use_lora': request.form.get('style_use_lora') == 'on'},
'scene': {'scene_id': _entity_id(request.form.get('scene_id')), 'scene': {'scene_id': _entity_id(request.form.get('scene_id')),
@@ -284,6 +310,11 @@ def register_routes(app):
'use_lora': request.form.get('detailer_use_lora') == 'on'}, 'use_lora': request.form.get('detailer_use_lora') == 'on'},
'look': {'look_id': _entity_id(request.form.get('look_id'))}, 'look': {'look_id': _entity_id(request.form.get('look_id'))},
'checkpoint': {'checkpoint_path': _entity_id(request.form.get('checkpoint_path'))}, 'checkpoint': {'checkpoint_path': _entity_id(request.form.get('checkpoint_path'))},
'resolution': {
'width': int(request.form.get('res_width', 1024)),
'height': int(request.form.get('res_height', 1024)),
'random': request.form.get('res_random') == 'on',
},
'tags': [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()], 'tags': [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()],
} }
@@ -399,20 +430,21 @@ def register_routes(app):
preset_data = { preset_data = {
'character': {'character_id': 'random', 'use_lora': True, 'character': {'character_id': 'random', 'use_lora': True,
'fields': { 'fields': {
'identity': {k: True for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']}, 'identity': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
'defaults': {k: False for k in ['expression', 'pose', 'scene']}, 'defaults': {k: False for k in ['expression', 'pose', 'scene']},
'wardrobe': {'outfit': 'default', 'wardrobe': {'outfit': 'default',
'fields': {k: True for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}}, 'fields': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
}}, }},
'outfit': {'outfit_id': None, 'use_lora': True}, 'outfit': {'outfit_id': None, 'use_lora': True},
'action': {'action_id': None, 'use_lora': True, 'action': {'action_id': None, 'use_lora': True,
'fields': {k: True for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}}, 'fields': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
'style': {'style_id': None, 'use_lora': True}, 'style': {'style_id': None, 'use_lora': True},
'scene': {'scene_id': None, 'use_lora': True, 'scene': {'scene_id': None, 'use_lora': True,
'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}}, 'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}},
'detailer': {'detailer_id': None, 'use_lora': True}, 'detailer': {'detailer_id': None, 'use_lora': True},
'look': {'look_id': None}, 'look': {'look_id': None},
'checkpoint': {'checkpoint_path': None}, 'checkpoint': {'checkpoint_path': None},
'resolution': {'width': 1024, 'height': 1024, 'random': False},
'tags': [], 'tags': [],
} }

25
routes/quick.py Normal file
View File

@@ -0,0 +1,25 @@
import logging
from flask import render_template
from models import Preset
from services.file_io import get_available_checkpoints
from services.comfyui import get_loaded_checkpoint
from services.workflow import _get_default_checkpoint
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/quick')
def quick_generator():
presets = Preset.query.order_by(Preset.name).all()
checkpoints = get_available_checkpoints()
# Default to whatever is currently loaded in ComfyUI, then settings default
selected_ckpt = get_loaded_checkpoint()
if not selected_ckpt:
default_path, _ = _get_default_checkpoint()
selected_ckpt = default_path
return render_template('quick.html', presets=presets,
checkpoints=checkpoints, selected_ckpt=selected_ckpt)

View File

@@ -202,7 +202,7 @@ def register_routes(app):
else: else:
# Auto-include essential character fields (minimal set for batch/default generation) # Auto-include essential character fields (minimal set for batch/default generation)
selected_fields = [] selected_fields = []
for key in ['base_specs', 'hair', 'eyes']: for key in ['base', 'head']:
if character.data.get('identity', {}).get(key): if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}') selected_fields.append(f'identity::{key}')
selected_fields.append('special::name') selected_fields.append('special::name')

View File

@@ -78,11 +78,11 @@ def register_routes(app):
if character: if character:
identity = character.data.get('identity', {}) identity = character.data.get('identity', {})
defaults = character.data.get('defaults', {}) defaults = character.data.get('defaults', {})
char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'), char_parts = [v for v in [identity.get('base'), identity.get('head'),
identity.get('eyes'), defaults.get('expression')] if v]
face_parts = [v for v in [identity.get('hair'), identity.get('eyes'),
defaults.get('expression')] if v] defaults.get('expression')] if v]
hand_parts = [v for v in [wardrobe.get('hands'), wardrobe.get('gloves')] if v] face_parts = [v for v in [identity.get('head'),
defaults.get('expression')] if v]
hand_parts = [v for v in [wardrobe.get('hands')] if v]
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags
return { return {
'main': _dedup_tags(', '.join(p for p in main_parts if p)), 'main': _dedup_tags(', '.join(p for p in main_parts if p)),
@@ -94,17 +94,16 @@ def register_routes(app):
action_data = entity.data.get('action', {}) action_data = entity.data.get('action', {})
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '') action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', []) tags = entity.data.get('tags', [])
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional'] from utils import _BODY_GROUP_KEYS
pose_parts = [action_data.get(k, '') for k in pose_fields if action_data.get(k)] pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
expr_parts = [action_data.get(k, '') for k in ['head', 'eyes'] if action_data.get(k)] expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
char_parts = [] char_parts = []
face_parts = list(expr_parts) face_parts = list(expr_parts)
hand_parts = [action_data.get('hands', '')] if action_data.get('hands') else [] hand_parts = [action_data.get('hands', '')] if action_data.get('hands') else []
if character: if character:
identity = character.data.get('identity', {}) identity = character.data.get('identity', {})
char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'), char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
identity.get('eyes')] if v] face_parts = [v for v in [identity.get('head')] + expr_parts if v]
face_parts = [v for v in [identity.get('hair'), identity.get('eyes')] + expr_parts if v]
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
return { return {
'main': _dedup_tags(', '.join(p for p in main_parts if p)), 'main': _dedup_tags(', '.join(p for p in main_parts if p)),
@@ -130,15 +129,15 @@ def register_routes(app):
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p] entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p]
char_data_no_lora = _get_character_data_without_lora(character) char_data_no_lora = _get_character_data_without_lora(character)
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': ''} base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''}
entity_str = ', '.join(entity_parts) entity_str = ', '.join(entity_parts)
if entity_str: if entity_str:
base['main'] = f"{base['main']}, {entity_str}" if base['main'] else entity_str base['main'] = f"{base['main']}, {entity_str}" if base['main'] else entity_str
if action is not None: if action is not None:
action_data = action.data.get('action', {}) action_data = action.data.get('action', {})
action_parts = [action_data.get(k, '') for k in from utils import _BODY_GROUP_KEYS
['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional', 'head', 'eyes'] action_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS
if action_data.get(k)] if action_data.get(k)]
action_str = ', '.join(action_parts) action_str = ', '.join(action_parts)
if action_str: if action_str:

View File

@@ -42,7 +42,7 @@ def register_routes(app):
else: else:
# Auto-include essential character fields (minimal set for batch/default generation) # Auto-include essential character fields (minimal set for batch/default generation)
selected_fields = [] selected_fields = []
for key in ['base_specs', 'hair', 'eyes']: for key in ['base', 'head']:
if character.data.get('identity', {}).get(key): if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}') selected_fields.append(f'identity::{key}')
selected_fields.append('special::name') selected_fields.append('special::name')

View File

@@ -88,8 +88,8 @@ def register_routes(app):
'outfit_id': slug, 'outfit_id': slug,
'outfit_name': name, 'outfit_name': name,
'wardrobe': source_data.get('wardrobe', { 'wardrobe': source_data.get('wardrobe', {
'full_body': '', 'headwear': '', 'top': '', 'bottom': '', 'base': '', 'head': '', 'upper_body': '', 'lower_body': '',
'legwear': '', 'footwear': '', 'hands': '', 'accessories': '' 'hands': '', 'feet': '', 'additional': ''
}), }),
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 0.8, 'lora_triggers': ''}), 'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 0.8, 'lora_triggers': ''}),
'tags': source_data.get('tags', []), 'tags': source_data.get('tags', []),
@@ -99,8 +99,8 @@ def register_routes(app):
'action_id': slug, 'action_id': slug,
'action_name': name, 'action_name': name,
'action': source_data.get('action', { 'action': source_data.get('action', {
'full_body': '', 'head': '', 'eyes': '', 'arms': '', 'hands': '', 'base': '', 'head': '', 'upper_body': '', 'lower_body': '',
'torso': '', 'pelvis': '', 'legs': '', 'feet': '', 'additional': '' 'hands': '', 'feet': '', 'additional': ''
}), }),
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}), 'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}),
'tags': source_data.get('tags', []), 'tags': source_data.get('tags', []),

View File

@@ -6,6 +6,22 @@ from flask import current_app
logger = logging.getLogger('gaze') logger = logging.getLogger('gaze')
def get_loaded_checkpoint():
"""Return the checkpoint path currently loaded in ComfyUI, or None."""
try:
url = current_app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188')
resp = requests.get(f'{url}/history', timeout=3)
if resp.ok:
history = resp.json()
if history:
latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', ''))
nodes = latest.get('prompt', [None, None, {}])[2]
return nodes.get('4', {}).get('inputs', {}).get('ckpt_name')
except Exception:
pass
return None
def _ensure_checkpoint_loaded(checkpoint_path): def _ensure_checkpoint_loaded(checkpoint_path):
"""Check if the desired checkpoint is loaded in ComfyUI, and force reload if not.""" """Check if the desired checkpoint is loaded in ComfyUI, and force reload if not."""
if not checkpoint_path: if not checkpoint_path:

View File

@@ -1,6 +1,6 @@
import re import re
from models import db, Character from models import db, Character
from utils import _IDENTITY_KEYS, _WARDROBE_KEYS, parse_orientation from utils import _IDENTITY_KEYS, _WARDROBE_KEYS, _BODY_GROUP_KEYS, parse_orientation
def _dedup_tags(prompt_str): def _dedup_tags(prompt_str):
@@ -114,15 +114,21 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
style_data = data.get('style', {}) style_data = data.get('style', {})
participants = data.get('participants', {}) participants = data.get('participants', {})
# Pre-calculate Hand/Glove priority # Helper: collect selected values from identity + wardrobe for a given group key
# Priority: wardrobe gloves > wardrobe hands (outfit) > identity hands (character) def _group_val(key):
hand_val = "" parts = []
if wardrobe.get('gloves') and is_selected('wardrobe', 'gloves'): id_val = identity.get(key, '')
hand_val = wardrobe.get('gloves') wd_val = wardrobe.get(key, '')
elif wardrobe.get('hands') and is_selected('wardrobe', 'hands'): if id_val and is_selected('identity', key):
hand_val = wardrobe.get('hands') val = id_val
elif identity.get('hands') and is_selected('identity', 'hands'): # Filter out conflicting tags from base if participants data is present
hand_val = identity.get('hands') if participants and key == 'base':
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
if val:
parts.append(val)
if wd_val and is_selected('wardrobe', key):
parts.append(wd_val)
return ', '.join(parts)
# 1. Main Prompt # 1. Main Prompt
parts = [] parts = []
@@ -131,12 +137,10 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
if participants: if participants:
if participants.get('solo_focus') == 'true': if participants.get('solo_focus') == 'true':
parts.append('(solo focus:1.2)') parts.append('(solo focus:1.2)')
orientation = participants.get('orientation', '') orientation = participants.get('orientation', '')
if orientation: if orientation:
parts.extend(parse_orientation(orientation)) parts.extend(parse_orientation(orientation))
else: else:
# Default behavior
parts.append("(solo:1.2)") parts.append("(solo:1.2)")
# Use character_id (underscores to spaces) for tags compatibility # Use character_id (underscores to spaces) for tags compatibility
@@ -144,13 +148,10 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
if char_tag and is_selected('special', 'name'): if char_tag and is_selected('special', 'name'):
parts.append(char_tag) parts.append(char_tag)
for key in ['base_specs', 'hair', 'eyes', 'extra']: # Add all body groups to main prompt
val = identity.get(key) for key in _BODY_GROUP_KEYS:
if val and is_selected('identity', key): val = _group_val(key)
# Filter out conflicting tags if participants data is present if val:
if participants and key == 'base_specs':
# Remove 1girl, 1boy, solo, etc.
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
parts.append(val) parts.append(val)
# Add defaults (expression, pose, scene) # Add defaults (expression, pose, scene)
@@ -159,21 +160,12 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
if val and is_selected('defaults', key): if val and is_selected('defaults', key):
parts.append(val) parts.append(val)
# Add hand priority value to main prompt
if hand_val:
parts.append(hand_val)
for key in ['full_body', 'top', 'bottom', 'headwear', 'legwear', 'footwear', 'accessories']:
val = wardrobe.get(key)
if val and is_selected('wardrobe', key):
parts.append(val)
# Standard character styles # Standard character styles
char_aesthetic = data.get('styles', {}).get('aesthetic') char_aesthetic = data.get('styles', {}).get('aesthetic')
if char_aesthetic and is_selected('styles', 'aesthetic'): if char_aesthetic and is_selected('styles', 'aesthetic'):
parts.append(f"{char_aesthetic} style") parts.append(f"{char_aesthetic} style")
# New Styles Gallery logic # Styles Gallery logic
if style_data.get('artist_name') and is_selected('style', 'artist_name'): if style_data.get('artist_name') and is_selected('style', 'artist_name'):
parts.append(f"by {style_data['artist_name']}") parts.append(f"by {style_data['artist_name']}")
if style_data.get('artistic_style') and is_selected('style', 'artistic_style'): if style_data.get('artistic_style') and is_selected('style', 'artistic_style'):
@@ -187,26 +179,98 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'): if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'):
parts.append(lora.get('lora_triggers')) parts.append(lora.get('lora_triggers'))
# 2. Face Prompt: Tag, Eyes, Expression, Headwear, Action details # 2. Face Prompt: head group + expression + action head
face_parts = [] face_parts = []
if char_tag and is_selected('special', 'name'): face_parts.append(char_tag) if char_tag and is_selected('special', 'name'):
if identity.get('eyes') and is_selected('identity', 'eyes'): face_parts.append(identity.get('eyes')) face_parts.append(char_tag)
if defaults.get('expression') and is_selected('defaults', 'expression'): face_parts.append(defaults.get('expression')) head_val = _group_val('head')
if wardrobe.get('headwear') and is_selected('wardrobe', 'headwear'): face_parts.append(wardrobe.get('headwear')) if head_val:
face_parts.append(head_val)
if defaults.get('expression') and is_selected('defaults', 'expression'):
face_parts.append(defaults.get('expression'))
if action_data.get('head') and is_selected('action', 'head'):
face_parts.append(action_data.get('head'))
# Add specific Action expression details if available # 3. Hand Prompt: hands group + action hands
if action_data.get('head') and is_selected('action', 'head'): face_parts.append(action_data.get('head')) hand_parts = []
if action_data.get('eyes') and is_selected('action', 'eyes'): face_parts.append(action_data.get('eyes')) hands_val = _group_val('hands')
if hands_val:
hand_parts.append(hands_val)
if action_data.get('hands') and is_selected('action', 'hands'):
hand_parts.append(action_data.get('hands'))
# 3. Hand Prompt: Hand value (Gloves or Hands), Action details # 4. Feet Prompt: feet group + action feet
hand_parts = [hand_val] if hand_val else [] feet_parts = []
if action_data.get('arms') and is_selected('action', 'arms'): hand_parts.append(action_data.get('arms')) feet_val = _group_val('feet')
if action_data.get('hands') and is_selected('action', 'hands'): hand_parts.append(action_data.get('hands')) if feet_val:
feet_parts.append(feet_val)
if action_data.get('feet') and is_selected('action', 'feet'):
feet_parts.append(action_data.get('feet'))
return { return {
"main": _dedup_tags(", ".join(parts)), "main": _dedup_tags(", ".join(parts)),
"face": _dedup_tags(", ".join(face_parts)), "face": _dedup_tags(", ".join(face_parts)),
"hand": _dedup_tags(", ".join(hand_parts)) "hand": _dedup_tags(", ".join(hand_parts)),
"feet": _dedup_tags(", ".join(feet_parts)),
}
def build_multi_prompt(char_a, char_b, extras_prompt=''):
"""Build prompts for a two-character generation using BREAK separation.
Returns dict with combined prompts (main, face, hand) and per-character
prompts (char_a_main, char_a_face, char_b_main, char_b_face) for the
per-person/face ADetailer passes.
"""
# Build individual prompts with all fields selected
prompts_a = build_prompt(char_a.data)
prompts_b = build_prompt(char_b.data)
# Strip solo/orientation tags from individual prompts — we'll add combined ones
_solo_orientation_tags = {
'solo', '(solo:1.2)', '(solo focus:1.2)',
'1girl', '1boy', '2girls', '2boys', '3girls', '3boys',
'hetero', 'yuri', 'yaoi',
'multiple girls', 'multiple boys',
}
def _strip_tags(prompt_str, tags_to_remove):
parts = [t.strip() for t in prompt_str.split(',') if t.strip()]
return ', '.join(p for p in parts if p.lower() not in tags_to_remove)
main_a = _strip_tags(prompts_a['main'], _solo_orientation_tags)
main_b = _strip_tags(prompts_b['main'], _solo_orientation_tags)
# Compute combined orientation
orient_a = char_a.data.get('participants', {}).get('orientation', '1F')
orient_b = char_b.data.get('participants', {}).get('orientation', '1F')
# Count total M and F across both characters
combined_m = orient_a.upper().count('M') + orient_b.upper().count('M')
combined_f = orient_a.upper().count('F') + orient_b.upper().count('F')
combined_orientation = f"{combined_m}M{combined_f}F" if combined_m else f"{combined_f}F"
if combined_f == 0:
combined_orientation = f"{combined_m}M"
orientation_tags = parse_orientation(combined_orientation)
# Build combined main prompt with BREAK separation
orientation_str = ', '.join(orientation_tags)
combined_main = f"{orientation_str}, {main_a} BREAK {main_b}"
if extras_prompt:
combined_main = f"{extras_prompt}, {combined_main}"
# Merge face/hand prompts for the hand detailer (shared, not per-character)
hand_parts = [p for p in [prompts_a['hand'], prompts_b['hand']] if p]
return {
"main": _dedup_tags(combined_main),
"face": "", # not used — per-character face prompts go to SEGS detailers
"hand": _dedup_tags(', '.join(hand_parts)),
# Per-character prompts for SEGS-based ADetailer passes
"char_a_main": _dedup_tags(main_a),
"char_a_face": _dedup_tags(prompts_a['face']),
"char_b_main": _dedup_tags(main_b),
"char_b_face": _dedup_tags(prompts_b['face']),
} }
@@ -220,7 +284,7 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
if lora.get('lora_triggers'): if lora.get('lora_triggers'):
parts.append(lora['lora_triggers']) parts.append(lora['lora_triggers'])
parts.extend(data.get('tags', [])) parts.extend(data.get('tags', []))
for key in ['full_body', 'additional']: for key in _BODY_GROUP_KEYS:
val = data.get('action', {}).get(key) val = data.get('action', {}).get(key)
if val: if val:
parts.append(val) parts.append(val)
@@ -228,7 +292,7 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
for outfit in outfits: for outfit in outfits:
data = outfit.data data = outfit.data
wardrobe = data.get('wardrobe', {}) wardrobe = data.get('wardrobe', {})
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'accessories']: for key in _BODY_GROUP_KEYS:
val = wardrobe.get(key) val = wardrobe.get(key)
if val: if val:
parts.append(val) parts.append(val)

View File

@@ -153,14 +153,13 @@ def ensure_default_outfit():
"outfit_id": "default", "outfit_id": "default",
"outfit_name": "Default", "outfit_name": "Default",
"wardrobe": { "wardrobe": {
"full_body": "", "base": "",
"headwear": "", "head": "",
"top": "", "upper_body": "",
"bottom": "", "lower_body": "",
"legwear": "",
"footwear": "",
"hands": "", "hands": "",
"accessories": "" "feet": "",
"additional": ""
}, },
"lora": { "lora": {
"lora_name": "", "lora_name": "",
@@ -360,7 +359,8 @@ def _resolve_preset_fields(preset_data):
char_cfg = preset_data.get('character', {}) char_cfg = preset_data.get('character', {})
fields = char_cfg.get('fields', {}) fields = char_cfg.get('fields', {})
for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: from utils import _BODY_GROUP_KEYS
for key in _BODY_GROUP_KEYS:
val = fields.get('identity', {}).get(key, True) val = fields.get('identity', {}).get(key, True)
if val == 'random': if val == 'random':
val = random.choice([True, False]) val = random.choice([True, False])
@@ -375,7 +375,7 @@ def _resolve_preset_fields(preset_data):
selected.append(f'defaults::{key}') selected.append(f'defaults::{key}')
wardrobe_cfg = fields.get('wardrobe', {}) wardrobe_cfg = fields.get('wardrobe', {})
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: for key in _BODY_GROUP_KEYS:
val = wardrobe_cfg.get('fields', {}).get(key, True) val = wardrobe_cfg.get('fields', {}).get(key, True)
if val == 'random': if val == 'random':
val = random.choice([True, False]) val = random.choice([True, False])

View File

@@ -9,6 +9,11 @@ from services.prompts import _cross_dedup_prompts
logger = logging.getLogger('gaze') logger = logging.getLogger('gaze')
# Node IDs used by DetailerForEach in multi-char mode
_SEGS_DETAILER_NODES = ['46', '47', '53', '54']
# Node IDs for per-character CLIP prompts in multi-char mode
_SEGS_PROMPT_NODES = ['44', '45', '51', '52']
def _log_workflow_prompts(label, workflow): def _log_workflow_prompts(label, workflow):
"""Log the final assembled ComfyUI prompts in a consistent, readable block.""" """Log the final assembled ComfyUI prompts in a consistent, readable block."""
@@ -17,7 +22,7 @@ def _log_workflow_prompts(label, workflow):
lora_details = [] lora_details = []
# Collect detailed LoRA information # Collect detailed LoRA information
for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]: for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene"), ("20", "char_b")]:
if node_id in workflow: if node_id in workflow:
name = workflow[node_id]["inputs"].get("lora_name", "") name = workflow[node_id]["inputs"].get("lora_name", "")
if name: if name:
@@ -41,11 +46,18 @@ def _log_workflow_prompts(label, workflow):
# Extract adetailer information # Extract adetailer information
adetailer_info = [] adetailer_info = []
# Single-char mode: FaceDetailer nodes 11 + 13
for node_id, node_name in [("11", "Face"), ("13", "Hand")]: for node_id, node_name in [("11", "Face"), ("13", "Hand")]:
if node_id in workflow: if node_id in workflow:
adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, " adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, "
f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, " f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, "
f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}") f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}")
# Multi-char mode: SEGS DetailerForEach nodes
for node_id, node_name in [("46", "Person A"), ("47", "Person B"), ("53", "Face A"), ("54", "Face B")]:
if node_id in workflow:
adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, "
f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, "
f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}")
face_text = workflow.get('14', {}).get('inputs', {}).get('text', '') face_text = workflow.get('14', {}).get('inputs', {}).get('text', '')
hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '') hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '')
@@ -95,6 +107,12 @@ def _log_workflow_prompts(label, workflow):
if hand_text: if hand_text:
lines.append(f" [H] Hand : {hand_text}") lines.append(f" [H] Hand : {hand_text}")
# Multi-char per-character prompts
for node_id, lbl in [("44", "Person A"), ("45", "Person B"), ("51", "Face A"), ("52", "Face B")]:
txt = workflow.get(node_id, {}).get('inputs', {}).get('text', '')
if txt:
lines.append(f" [{lbl}] : {txt}")
lines.append(sep) lines.append(sep)
logger.info("\n%s", "\n".join(lines)) logger.info("\n%s", "\n".join(lines))
@@ -119,8 +137,8 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
if scheduler and '3' in workflow: if scheduler and '3' in workflow:
workflow['3']['inputs']['scheduler'] = scheduler workflow['3']['inputs']['scheduler'] = scheduler
# Face/hand detailers (nodes 11, 13) # Face/hand detailers (nodes 11, 13) + multi-char SEGS detailers
for node_id in ['11', '13']: for node_id in ['11', '13'] + _SEGS_DETAILER_NODES:
if node_id in workflow: if node_id in workflow:
if steps: if steps:
workflow[node_id]['inputs']['steps'] = int(steps) workflow[node_id]['inputs']['steps'] = int(steps)
@@ -131,9 +149,9 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
if scheduler: if scheduler:
workflow[node_id]['inputs']['scheduler'] = scheduler workflow[node_id]['inputs']['scheduler'] = scheduler
# Prepend base_positive to positive prompts (main + face/hand detailers) # Prepend base_positive to all positive prompt nodes
if base_positive: if base_positive:
for node_id in ['6', '14', '15']: for node_id in ['6', '14', '15'] + _SEGS_PROMPT_NODES:
if node_id in workflow: if node_id in workflow:
workflow[node_id]['inputs']['text'] = f"{base_positive}, {workflow[node_id]['inputs']['text']}" workflow[node_id]['inputs']['text'] = f"{base_positive}, {workflow[node_id]['inputs']['text']}"
@@ -149,7 +167,7 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
} }
if '8' in workflow: if '8' in workflow:
workflow['8']['inputs']['vae'] = ['21', 0] workflow['8']['inputs']['vae'] = ['21', 0]
for node_id in ['11', '13']: for node_id in ['11', '13'] + _SEGS_DETAILER_NODES:
if node_id in workflow: if node_id in workflow:
workflow[node_id]['inputs']['vae'] = ['21', 0] workflow[node_id]['inputs']['vae'] = ['21', 0]
@@ -187,12 +205,246 @@ def _get_default_checkpoint():
return ckpt.checkpoint_path, ckpt.data or {} return ckpt.checkpoint_path, ckpt.data or {}
def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None, action=None, style=None, detailer=None, scene=None, width=None, height=None, checkpoint_data=None, look=None, fixed_seed=None): def _inject_multi_char_detailers(workflow, prompts, model_source, clip_source):
"""Replace single FaceDetailer (node 11) with per-character SEGS-based detailers.
Injects person detection + face detection pipelines that order detections
left-to-right and apply character A's prompt to the left person/face and
character B's prompt to the right person/face.
Nodes added:
40 - Person detector (UltralyticsDetectorProvider)
41 - Person SEGS detection (BboxDetectorSEGS)
42 - Filter: left person (char A)
43 - Filter: right person (char B)
44 - CLIPTextEncode: char A body prompt
45 - CLIPTextEncode: char B body prompt
46 - DetailerForEach: person A
47 - DetailerForEach: person B
48 - Face SEGS detection (BboxDetectorSEGS, reuses face detector node 10)
49 - Filter: left face (char A)
50 - Filter: right face (char B)
51 - CLIPTextEncode: char A face prompt
52 - CLIPTextEncode: char B face prompt
53 - DetailerForEach: face A
54 - DetailerForEach: face B
Image flow: VAEDecode(8) → PersonA(46) → PersonB(47) → FaceA(53) → FaceB(54) → Hand(13)
"""
vae_source = ["4", 2]
# Remove old single face detailer and its prompt — we replace them
workflow.pop('11', None)
workflow.pop('14', None)
# --- Person detection ---
workflow['40'] = {
'inputs': {'model_name': 'segm/person_yolov8m-seg.pt'},
'class_type': 'UltralyticsDetectorProvider'
}
workflow['41'] = {
'inputs': {
'bbox_detector': ['40', 0],
'image': ['8', 0],
'threshold': 0.5,
'dilation': 10,
'crop_factor': 3.0,
'drop_size': 10,
'labels': 'all',
},
'class_type': 'BboxDetectorSEGS'
}
# Order by x1 ascending (left to right), pick index 0 = leftmost person
workflow['42'] = {
'inputs': {
'segs': ['41', 0],
'target': 'x1',
'order': False,
'take_start': 0,
'take_count': 1,
},
'class_type': 'ImpactSEGSOrderedFilter'
}
# Pick index 1 = rightmost person
workflow['43'] = {
'inputs': {
'segs': ['41', 0],
'target': 'x1',
'order': False,
'take_start': 1,
'take_count': 1,
},
'class_type': 'ImpactSEGSOrderedFilter'
}
# --- Per-character body prompts ---
workflow['44'] = {
'inputs': {'text': prompts.get('char_a_main', ''), 'clip': clip_source},
'class_type': 'CLIPTextEncode'
}
workflow['45'] = {
'inputs': {'text': prompts.get('char_b_main', ''), 'clip': clip_source},
'class_type': 'CLIPTextEncode'
}
# --- Person detailing (DetailerForEach) ---
_person_base = {
'guide_size': 512,
'guide_size_for': True,
'max_size': 1024,
'seed': 0, # overwritten by seed step
'steps': 20, # overwritten by checkpoint settings
'cfg': 3.5, # overwritten by checkpoint settings
'sampler_name': 'euler_ancestral',
'scheduler': 'normal',
'denoise': 0.4,
'feather': 5,
'noise_mask': True,
'force_inpaint': True,
'wildcard': '',
'cycle': 1,
'inpaint_model': False,
'noise_mask_feather': 20,
}
workflow['46'] = {
'inputs': {
**_person_base,
'image': ['8', 0],
'segs': ['42', 0],
'model': model_source,
'clip': clip_source,
'vae': vae_source,
'positive': ['44', 0],
'negative': ['7', 0],
},
'class_type': 'DetailerForEach'
}
workflow['47'] = {
'inputs': {
**_person_base,
'image': ['46', 0], # chains from person A output
'segs': ['43', 0],
'model': model_source,
'clip': clip_source,
'vae': vae_source,
'positive': ['45', 0],
'negative': ['7', 0],
},
'class_type': 'DetailerForEach'
}
# --- Face detection (on person-detailed image) ---
workflow['48'] = {
'inputs': {
'bbox_detector': ['10', 0], # reuse existing face YOLO detector
'image': ['47', 0],
'threshold': 0.5,
'dilation': 10,
'crop_factor': 3.0,
'drop_size': 10,
'labels': 'all',
},
'class_type': 'BboxDetectorSEGS'
}
workflow['49'] = {
'inputs': {
'segs': ['48', 0],
'target': 'x1',
'order': False,
'take_start': 0,
'take_count': 1,
},
'class_type': 'ImpactSEGSOrderedFilter'
}
workflow['50'] = {
'inputs': {
'segs': ['48', 0],
'target': 'x1',
'order': False,
'take_start': 1,
'take_count': 1,
},
'class_type': 'ImpactSEGSOrderedFilter'
}
# --- Per-character face prompts ---
workflow['51'] = {
'inputs': {'text': prompts.get('char_a_face', ''), 'clip': clip_source},
'class_type': 'CLIPTextEncode'
}
workflow['52'] = {
'inputs': {'text': prompts.get('char_b_face', ''), 'clip': clip_source},
'class_type': 'CLIPTextEncode'
}
# --- Face detailing (DetailerForEach) ---
_face_base = {
'guide_size': 384,
'guide_size_for': True,
'max_size': 1024,
'seed': 0,
'steps': 20,
'cfg': 3.5,
'sampler_name': 'euler_ancestral',
'scheduler': 'normal',
'denoise': 0.5,
'feather': 5,
'noise_mask': True,
'force_inpaint': True,
'wildcard': '',
'cycle': 1,
'inpaint_model': False,
'noise_mask_feather': 20,
}
workflow['53'] = {
'inputs': {
**_face_base,
'image': ['47', 0],
'segs': ['49', 0],
'model': model_source,
'clip': clip_source,
'vae': vae_source,
'positive': ['51', 0],
'negative': ['7', 0],
},
'class_type': 'DetailerForEach'
}
workflow['54'] = {
'inputs': {
**_face_base,
'image': ['53', 0], # chains from face A output
'segs': ['50', 0],
'model': model_source,
'clip': clip_source,
'vae': vae_source,
'positive': ['52', 0],
'negative': ['7', 0],
},
'class_type': 'DetailerForEach'
}
# Rewire hand detailer: image input from last face detailer instead of old node 11
if '13' in workflow:
workflow['13']['inputs']['image'] = ['54', 0]
logger.debug("Injected multi-char SEGS detailers (nodes 40-54)")
def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None, action=None, style=None, detailer=None, scene=None, width=None, height=None, checkpoint_data=None, look=None, fixed_seed=None, character_b=None):
# 1. Update prompts using replacement to preserve embeddings # 1. Update prompts using replacement to preserve embeddings
workflow["6"]["inputs"]["text"] = workflow["6"]["inputs"]["text"].replace("{{POSITIVE_PROMPT}}", prompts["main"]) workflow["6"]["inputs"]["text"] = workflow["6"]["inputs"]["text"].replace("{{POSITIVE_PROMPT}}", prompts["main"])
if custom_negative: if custom_negative:
workflow["7"]["inputs"]["text"] = f"{workflow['7']['inputs']['text']}, {custom_negative}" workflow["7"]["inputs"]["text"] = f"{custom_negative}, {workflow['7']['inputs']['text']}"
if "14" in workflow: if "14" in workflow:
workflow["14"]["inputs"]["text"] = workflow["14"]["inputs"]["text"].replace("{{FACE_PROMPT}}", prompts["face"]) workflow["14"]["inputs"]["text"] = workflow["14"]["inputs"]["text"].replace("{{FACE_PROMPT}}", prompts["face"])
@@ -289,23 +541,39 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
clip_source = ["19", 1] clip_source = ["19", 1]
logger.debug("Style/Detailer LoRA: %s @ %s", style_lora_name, _w19) logger.debug("Style/Detailer LoRA: %s @ %s", style_lora_name, _w19)
# Apply connections to all model/clip consumers # Second character LoRA (Node 20) - for multi-character generation
workflow["3"]["inputs"]["model"] = model_source if character_b:
workflow["11"]["inputs"]["model"] = model_source char_b_lora_data = character_b.data.get('lora', {})
workflow["13"]["inputs"]["model"] = model_source char_b_lora_name = char_b_lora_data.get('lora_name')
if char_b_lora_name and "20" in workflow:
_w20 = _resolve_lora_weight(char_b_lora_data)
workflow["20"]["inputs"]["lora_name"] = char_b_lora_name
workflow["20"]["inputs"]["strength_model"] = _w20
workflow["20"]["inputs"]["strength_clip"] = _w20
workflow["20"]["inputs"]["model"] = model_source
workflow["20"]["inputs"]["clip"] = clip_source
model_source = ["20", 0]
clip_source = ["20", 1]
logger.debug("Character B LoRA: %s @ %s", char_b_lora_name, _w20)
workflow["6"]["inputs"]["clip"] = clip_source # 3b. Multi-char: inject per-character SEGS detailers (replaces node 11/14)
workflow["7"]["inputs"]["clip"] = clip_source if character_b:
workflow["11"]["inputs"]["clip"] = clip_source _inject_multi_char_detailers(workflow, prompts, model_source, clip_source)
workflow["13"]["inputs"]["clip"] = clip_source
workflow["14"]["inputs"]["clip"] = clip_source # Apply connections to all model/clip consumers (conditional on node existence)
workflow["15"]["inputs"]["clip"] = clip_source for nid in ["3", "11", "13"] + _SEGS_DETAILER_NODES:
if nid in workflow:
workflow[nid]["inputs"]["model"] = model_source
for nid in ["6", "7", "11", "13", "14", "15"] + _SEGS_PROMPT_NODES:
if nid in workflow:
workflow[nid]["inputs"]["clip"] = clip_source
# 4. Randomize seeds (or use a fixed seed for reproducible batches like Strengths Gallery) # 4. Randomize seeds (or use a fixed seed for reproducible batches like Strengths Gallery)
gen_seed = fixed_seed if fixed_seed is not None else random.randint(1, 10**15) gen_seed = fixed_seed if fixed_seed is not None else random.randint(1, 10**15)
workflow["3"]["inputs"]["seed"] = gen_seed for nid in ["3", "11", "13"] + _SEGS_DETAILER_NODES:
if "11" in workflow: workflow["11"]["inputs"]["seed"] = gen_seed if nid in workflow:
if "13" in workflow: workflow["13"]["inputs"]["seed"] = gen_seed workflow[nid]["inputs"]["seed"] = gen_seed
# 5. Set image dimensions # 5. Set image dimensions
if "5" in workflow: if "5" in workflow:
@@ -321,7 +589,7 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
# 7. Sync sampler/scheduler from main KSampler to adetailer nodes # 7. Sync sampler/scheduler from main KSampler to adetailer nodes
sampler_name = workflow["3"]["inputs"].get("sampler_name") sampler_name = workflow["3"]["inputs"].get("sampler_name")
scheduler = workflow["3"]["inputs"].get("scheduler") scheduler = workflow["3"]["inputs"].get("scheduler")
for node_id in ["11", "13"]: for node_id in ["11", "13"] + _SEGS_DETAILER_NODES:
if node_id in workflow: if node_id in workflow:
if sampler_name: if sampler_name:
workflow[node_id]["inputs"]["sampler_name"] = sampler_name workflow[node_id]["inputs"]["sampler_name"] = sampler_name

View File

@@ -1736,3 +1736,47 @@ textarea[readonly] {
.gallery-grid.selection-mode .gallery-card:hover { .gallery-grid.selection-mode .gallery-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
/* --- Tag Widgets (Prompt Builder) -------------------------------- */
.tag-widget-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
min-height: 38px;
}
.tag-widget {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-family: var(--font-mono, 'SF Mono', monospace);
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.tag-widget.active {
background: var(--accent-dim);
color: #fff;
border-color: var(--accent);
}
.tag-widget.inactive {
background: var(--bg-raised);
color: var(--text-muted);
border-color: var(--border);
text-decoration: line-through;
opacity: 0.6;
}
.tag-widget:hover {
opacity: 1;
border-color: var(--accent-bright);
}

View File

@@ -153,12 +153,29 @@
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
</div> </div>
</div> </div>
<div class="tag-widget-container d-none" id="prompt-tags"></div>
<textarea class="form-control form-control-sm font-monospace" id="prompt-preview" <textarea class="form-control form-control-sm font-monospace" id="prompt-preview"
name="override_prompt" rows="5" name="override_prompt" rows="5"
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea> placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
<div class="form-text" id="preview-status"></div> <div class="form-text" id="preview-status"></div>
</div> </div>
<!-- ADetailer Prompt Previews -->
<div class="mb-3">
<label class="form-label mb-0">Face Detailer Prompt</label>
<div class="tag-widget-container d-none" id="face-tags"></div>
<textarea class="form-control form-control-sm font-monospace" id="face-preview"
name="override_face_prompt" rows="2"
placeholder="Auto-populated on Build — edit to override face detailer prompt."></textarea>
</div>
<div class="mb-3">
<label class="form-label mb-0">Hand Detailer Prompt</label>
<div class="tag-widget-container d-none" id="hand-tags"></div>
<textarea class="form-control form-control-sm font-monospace" id="hand-preview"
name="override_hand_prompt" rows="2"
placeholder="Auto-populated on Build — edit to override hand detailer prompt."></textarea>
</div>
<!-- Additional prompts --> <!-- Additional prompts -->
<div class="mb-3"> <div class="mb-3">
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label> <label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
@@ -246,7 +263,9 @@
randomizeCategory(field, key); randomizeCategory(field, key);
}); });
document.getElementById('prompt-preview').value = ''; clearTagWidgets('prompt-tags', 'prompt-preview');
clearTagWidgets('face-tags', 'face-preview');
clearTagWidgets('hand-tags', 'hand-preview');
document.getElementById('preview-status').textContent = ''; document.getElementById('preview-status').textContent = '';
} }
@@ -273,6 +292,51 @@
}); });
}); });
// --- Tag Widget System ---
function populateTagWidgets(containerId, textareaId, promptStr) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
container.innerHTML = '';
if (!promptStr || !promptStr.trim()) {
container.classList.add('d-none');
return;
}
const tags = promptStr.split(',').map(t => t.trim()).filter(Boolean);
tags.forEach(tag => {
const el = document.createElement('span');
el.className = 'tag-widget active';
el.textContent = tag;
el.dataset.tag = tag;
el.addEventListener('click', () => {
el.classList.toggle('active');
el.classList.toggle('inactive');
rebuildFromTags(containerId, textareaId);
});
container.appendChild(el);
});
container.classList.remove('d-none');
textarea.classList.add('d-none');
}
function rebuildFromTags(containerId, textareaId) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
const activeTags = Array.from(container.querySelectorAll('.tag-widget.active'))
.map(el => el.dataset.tag);
textarea.value = activeTags.join(', ');
}
function clearTagWidgets(containerId, textareaId) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
container.innerHTML = '';
container.classList.add('d-none');
textarea.classList.remove('d-none');
textarea.value = '';
}
// --- Prompt preview --- // --- Prompt preview ---
async function buildPromptPreview() { async function buildPromptPreview() {
const charVal = document.getElementById('character').value; const charVal = document.getElementById('character').value;
@@ -288,7 +352,12 @@
status.textContent = 'Error: ' + data.error; status.textContent = 'Error: ' + data.error;
} else { } else {
document.getElementById('prompt-preview').value = data.prompt; document.getElementById('prompt-preview').value = data.prompt;
status.textContent = 'Auto-built — edit freely, or Clear to let the server rebuild on generate.'; document.getElementById('face-preview').value = data.face || '';
document.getElementById('hand-preview').value = data.hand || '';
populateTagWidgets('prompt-tags', 'prompt-preview', data.prompt);
populateTagWidgets('face-tags', 'face-preview', data.face || '');
populateTagWidgets('hand-tags', 'hand-preview', data.hand || '');
status.textContent = 'Click tags to toggle — Clear to reset.';
} }
} catch (err) { } catch (err) {
status.textContent = 'Request failed.'; status.textContent = 'Request failed.';
@@ -297,7 +366,9 @@
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview); document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
document.getElementById('clear-preview-btn').addEventListener('click', () => { document.getElementById('clear-preview-btn').addEventListener('click', () => {
document.getElementById('prompt-preview').value = ''; clearTagWidgets('prompt-tags', 'prompt-preview');
clearTagWidgets('face-tags', 'face-preview');
clearTagWidgets('hand-tags', 'hand-preview');
document.getElementById('preview-status').textContent = ''; document.getElementById('preview-status').textContent = '';
}); });

View File

@@ -4,6 +4,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2>Character Library</h2> <h2>Character Library</h2>
<div class="d-flex gap-1 align-items-center"> <div class="d-flex gap-1 align-items-center">
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('rescan') }}" method="post" class="d-contents"> <form action="{{ url_for('rescan') }}" method="post" class="d-contents">

View File

@@ -25,8 +25,9 @@
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a> <a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a> <a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
<div class="vr mx-1 d-none d-lg-block"></div> <div class="vr mx-1 d-none d-lg-block"></div>
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a> <a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
<a href="/quick" class="btn btn-sm btn-outline-light">Quick</a>
<a href="/multi" class="btn btn-sm btn-outline-light">Multi</a>
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a> <a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a> <a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div> <div class="vr mx-1 d-none d-lg-block"></div>

547
templates/multi_char.html Normal file
View File

@@ -0,0 +1,547 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-5">
<div id="progress-container" class="mb-3 d-none">
<label id="progress-label" class="form-label">Generating...</label>
<div class="progress" role="progressbar" aria-label="Generation Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-primary text-white">Multi-Character Generator</div>
<div class="card-body">
<form id="multi-form" action="{{ url_for('multi_char') }}" method="post">
<!-- Controls bar -->
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
<button type="submit" class="btn btn-primary" id="generate-btn" data-requires="comfyui">Generate</button>
<input type="number" id="num-images" value="1" min="1" max="999"
class="form-control form-control-sm" style="width:65px" title="Number of images">
<div class="input-group input-group-sm" style="width:180px">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="button" class="btn btn-outline-warning" id="endless-btn">Endless</button>
<button type="button" class="btn btn-danger d-none" id="stop-btn">Stop</button>
<div class="ms-auto form-check mb-0">
<input type="checkbox" class="form-check-input" id="lucky-dip">
<label class="form-check-label" for="lucky-dip">Lucky Dip</label>
</div>
</div>
<!-- Character A -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="char_a" class="form-label mb-0">Character A</label>
<button type="button" class="btn btn-sm btn-outline-secondary random-char-btn" data-target="char_a">Random</button>
</div>
<select class="form-select" id="char_a" name="char_a" required>
<option value="" disabled {% if not selected_char_a %}selected{% endif %}>Select character A...</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_char_a == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
</div>
<!-- Character B -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="char_b" class="form-label mb-0">Character B</label>
<button type="button" class="btn btn-sm btn-outline-secondary random-char-btn" data-target="char_b">Random</button>
</div>
<select class="form-select" id="char_b" name="char_b" required>
<option value="" disabled {% if not selected_char_b %}selected{% endif %}>Select character B...</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_char_b == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
</div>
<!-- Checkpoint -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="checkpoint" class="form-label mb-0">Checkpoint Model</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button>
</div>
<select class="form-select" id="checkpoint" name="checkpoint" required>
{% for ckpt in checkpoints %}
<option value="{{ ckpt }}">{{ ckpt }}</option>
{% endfor %}
</select>
</div>
<!-- Mix & Match -->
<div class="mb-3">
<label class="form-label">Mix &amp; Match
<small class="text-muted fw-normal ms-1">— first checked per category applies its LoRA</small>
</label>
<div class="accordion" id="mixAccordion">
{% set mix_categories = [
('Actions', 'action', actions, 'action_slugs'),
('Scenes', 'scene', scenes, 'scene_slugs'),
('Styles', 'style', styles, 'style_slugs'),
('Detailers', 'detailer', detailers, 'detailer_slugs'),
] %}
{% for cat_label, cat_key, cat_items, field_name in mix_categories %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2" type="button"
data-bs-toggle="collapse" data-bs-target="#mix-{{ cat_key }}">
{{ cat_label }}
<span class="badge bg-secondary rounded-pill ms-2" id="badge-{{ cat_key }}">0</span>
<span class="badge bg-light text-secondary border ms-2 px-2 py-1"
style="cursor:pointer;font-size:.7rem;font-weight:normal"
onclick="event.stopPropagation(); randomizeCategory('{{ field_name }}', '{{ cat_key }}')">Random</span>
</button>
</h2>
<div id="mix-{{ cat_key }}" class="accordion-collapse collapse">
<div class="accordion-body p-2">
<input type="text" class="form-control form-control-sm mb-2"
placeholder="Search {{ cat_label | lower }}..."
oninput="filterMixCategory(this, 'mixlist-{{ cat_key }}')">
<div id="mixlist-{{ cat_key }}" style="max-height:220px;overflow-y:auto;">
{% for item in cat_items %}
<label class="mix-item d-flex align-items-center gap-2 px-2 py-1 rounded"
data-name="{{ item.name | lower }}" style="cursor:pointer;">
<input type="checkbox" class="form-check-input flex-shrink-0"
name="{{ field_name }}" value="{{ item.slug }}"
onchange="updateMixBadge('{{ cat_key }}', '{{ field_name }}')">
{% if item.image_path %}
<img src="{{ url_for('static', filename='uploads/' + item.image_path) }}"
class="rounded flex-shrink-0" style="width:32px;height:32px;object-fit:cover">
{% else %}
<span class="rounded bg-light flex-shrink-0 d-inline-flex align-items-center justify-content-center text-muted"
style="width:32px;height:32px;font-size:9px;">N/A</span>
{% endif %}
<span class="small text-truncate">{{ item.name }}</span>
</label>
{% else %}
<p class="text-muted small p-2 mb-0">No {{ cat_label | lower }} found.</p>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Resolution -->
<div class="mb-3">
<label class="form-label">Resolution</label>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1024" data-h="1024">1:1</button>
<button type="button" class="btn btn-sm btn-secondary preset-btn" data-w="1152" data-h="896">4:3 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="896" data-h="1152">4:3 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1344" data-h="768">16:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1344">16:9 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1792" data-h="768">21:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1792">21:9 P</button>
</div>
<div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 small fw-semibold">W</label>
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
value="1152" min="64" max="4096" step="64" style="width:88px">
<span class="text-muted">&times;</span>
<label class="form-label mb-0 small fw-semibold">H</label>
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
value="896" min="64" max="4096" step="64" style="width:88px">
</div>
</div>
<!-- Prompt Previews -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">Prompt Previews</label>
<div class="d-flex gap-1">
<button type="button" class="btn btn-sm btn-outline-primary" id="build-preview-btn">Build</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
</div>
</div>
<div class="form-text mb-2" id="preview-status"></div>
<label class="form-label mb-0 small text-muted">Main (KSampler)</label>
<div class="tag-widget-container d-none mb-2" id="prompt-tags"></div>
<textarea class="form-control form-control-sm font-monospace mb-2" id="prompt-preview"
name="override_prompt" rows="4"
placeholder="Click Build to preview — this is the combined prompt sent to KSampler."></textarea>
<label class="form-label mb-0 small text-muted">Person A Detailer (left)</label>
<div class="tag-widget-container d-none mb-2" id="char-a-main-tags"></div>
<textarea class="form-control form-control-sm font-monospace mb-2" id="char-a-main-preview"
name="override_char_a_main" rows="2"
placeholder="Character A body prompt for person ADetailer."></textarea>
<label class="form-label mb-0 small text-muted">Person B Detailer (right)</label>
<div class="tag-widget-container d-none mb-2" id="char-b-main-tags"></div>
<textarea class="form-control form-control-sm font-monospace mb-2" id="char-b-main-preview"
name="override_char_b_main" rows="2"
placeholder="Character B body prompt for person ADetailer."></textarea>
<label class="form-label mb-0 small text-muted">Face A Detailer (left)</label>
<div class="tag-widget-container d-none mb-2" id="char-a-face-tags"></div>
<textarea class="form-control form-control-sm font-monospace mb-2" id="char-a-face-preview"
name="override_char_a_face" rows="2"
placeholder="Character A face prompt."></textarea>
<label class="form-label mb-0 small text-muted">Face B Detailer (right)</label>
<div class="tag-widget-container d-none mb-2" id="char-b-face-tags"></div>
<textarea class="form-control form-control-sm font-monospace mb-2" id="char-b-face-preview"
name="override_char_b_face" rows="2"
placeholder="Character B face prompt."></textarea>
</div>
<!-- Additional prompts -->
<div class="mb-3">
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
<textarea class="form-control" id="positive_prompt" name="positive_prompt" rows="2" placeholder="e.g. standing together, park, daylight"></textarea>
</div>
<div class="mb-3">
<label for="negative_prompt" class="form-label">Additional Negative Prompt</label>
<textarea class="form-control" id="negative_prompt" name="negative_prompt" rows="2" placeholder="e.g. bad hands, extra digits"></textarea>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card">
<div class="card-header bg-dark text-white">Result</div>
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container">
<div class="text-center text-muted" id="placeholder-text">
<p>Select two characters and click Generate</p>
</div>
<div class="img-container w-100 h-100 d-none">
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// --- Filtering ---
function filterMixCategory(input, listId) {
const query = input.value.toLowerCase();
document.querySelectorAll(`#${listId} .mix-item`).forEach(el => {
el.style.display = el.dataset.name.includes(query) ? '' : 'none';
});
}
function updateMixBadge(key, fieldName) {
const count = document.querySelectorAll(`input[name="${fieldName}"]:checked`).length;
const badge = document.getElementById(`badge-${key}`);
badge.textContent = count;
badge.className = count > 0
? 'badge bg-primary rounded-pill ms-2'
: 'badge bg-secondary rounded-pill ms-2';
}
// --- Randomizers ---
function randomizeCategory(fieldName, catKey) {
const cbs = Array.from(document.querySelectorAll(`input[name="${fieldName}"]`));
cbs.forEach(cb => cb.checked = false);
if (cbs.length) cbs[Math.floor(Math.random() * cbs.length)].checked = true;
updateMixBadge(catKey, fieldName);
}
function randomizeSelect(selectId) {
const opts = Array.from(document.getElementById(selectId).options).filter(o => o.value);
if (opts.length)
document.getElementById(selectId).value = opts[Math.floor(Math.random() * opts.length)].value;
}
function applyLuckyDip() {
randomizeSelect('char_a');
randomizeSelect('char_b');
randomizeSelect('checkpoint');
const presets = Array.from(document.querySelectorAll('.preset-btn'));
if (presets.length) presets[Math.floor(Math.random() * presets.length)].click();
[['action_slugs', 'action'], ['scene_slugs', 'scene'],
['style_slugs', 'style'], ['detailer_slugs', 'detailer']].forEach(([field, key]) => {
randomizeCategory(field, key);
});
}
// --- Resolution presets ---
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.preset-btn').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
});
});
['res-width', 'res-height'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
document.querySelectorAll('.preset-btn').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
});
});
// --- Tag Widget System ---
function populateTagWidgets(containerId, textareaId, promptStr, opts = {}) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
container.innerHTML = '';
if (!promptStr || !promptStr.trim()) {
container.classList.add('d-none');
return;
}
// Split on commas but preserve BREAK as a visual separator
const rawParts = promptStr.split(',').map(t => t.trim()).filter(Boolean);
rawParts.forEach(part => {
// Check if this part contains BREAK (e.g. "tag1 BREAK tag2")
if (part.includes('BREAK')) {
const breakParts = part.split(/\s+BREAK\s+/);
breakParts.forEach((bp, i) => {
if (bp.trim()) {
addTagEl(container, containerId, textareaId, bp.trim(), opts.readonly);
}
if (i < breakParts.length - 1) {
const sep = document.createElement('span');
sep.className = 'tag-widget';
sep.style.cssText = 'background:var(--accent-glow);color:var(--accent-bright);cursor:default;font-weight:600;pointer-events:none;';
sep.textContent = 'BREAK';
sep.dataset.break = 'true';
container.appendChild(sep);
}
});
} else {
addTagEl(container, containerId, textareaId, part, opts.readonly);
}
});
container.classList.remove('d-none');
textarea.classList.add('d-none');
}
function addTagEl(container, containerId, textareaId, tag, readonly) {
const el = document.createElement('span');
el.className = 'tag-widget active';
el.textContent = tag;
el.dataset.tag = tag;
if (!readonly) {
el.addEventListener('click', () => {
el.classList.toggle('active');
el.classList.toggle('inactive');
rebuildFromTags(containerId, textareaId);
});
} else {
el.style.cursor = 'default';
}
container.appendChild(el);
}
function rebuildFromTags(containerId, textareaId) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
const parts = [];
let currentGroup = [];
container.querySelectorAll('.tag-widget').forEach(el => {
if (el.dataset.break) {
if (currentGroup.length) parts.push(currentGroup.join(', '));
currentGroup = [];
} else if (el.classList.contains('active')) {
currentGroup.push(el.dataset.tag);
}
});
if (currentGroup.length) parts.push(currentGroup.join(', '));
textarea.value = parts.join(' BREAK ');
}
function clearTagWidgets(containerId, textareaId) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
container.innerHTML = '';
container.classList.add('d-none');
textarea.classList.remove('d-none');
textarea.value = '';
}
const TAG_PAIRS = [
['prompt-tags', 'prompt-preview'],
['char-a-main-tags', 'char-a-main-preview'],
['char-b-main-tags', 'char-b-main-preview'],
['char-a-face-tags', 'char-a-face-preview'],
['char-b-face-tags', 'char-b-face-preview'],
];
// --- Prompt preview ---
async function buildPromptPreview() {
const charA = document.getElementById('char_a').value;
const charB = document.getElementById('char_b').value;
const status = document.getElementById('preview-status');
if (!charA || !charB) { status.textContent = 'Select both characters first.'; return; }
status.textContent = 'Building...';
const formData = new FormData(document.getElementById('multi-form'));
try {
const resp = await fetch('/multi/preview_prompt', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) {
status.textContent = 'Error: ' + data.error;
} else {
document.getElementById('prompt-preview').value = data.prompt;
document.getElementById('char-a-main-preview').value = data.char_a_main || '';
document.getElementById('char-b-main-preview').value = data.char_b_main || '';
document.getElementById('char-a-face-preview').value = data.char_a_face || '';
document.getElementById('char-b-face-preview').value = data.char_b_face || '';
populateTagWidgets('prompt-tags', 'prompt-preview', data.prompt);
populateTagWidgets('char-a-main-tags', 'char-a-main-preview', data.char_a_main || '');
populateTagWidgets('char-b-main-tags', 'char-b-main-preview', data.char_b_main || '');
populateTagWidgets('char-a-face-tags', 'char-a-face-preview', data.char_a_face || '');
populateTagWidgets('char-b-face-tags', 'char-b-face-preview', data.char_b_face || '');
status.textContent = 'Click tags to toggle — Clear to reset.';
}
} catch (err) {
status.textContent = 'Request failed.';
}
}
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
document.getElementById('clear-preview-btn').addEventListener('click', () => {
TAG_PAIRS.forEach(([c, t]) => clearTagWidgets(c, t));
document.getElementById('preview-status').textContent = '';
});
// --- Main generation logic ---
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('multi-form');
const progressBar = document.getElementById('progress-bar');
const progressCont = document.getElementById('progress-container');
const progressLbl = document.getElementById('progress-label');
const generateBtn = document.getElementById('generate-btn');
const endlessBtn = document.getElementById('endless-btn');
const stopBtn = document.getElementById('stop-btn');
const numInput = document.getElementById('num-images');
const resultImg = document.getElementById('result-img');
const placeholder = document.getElementById('placeholder-text');
let currentJobId = null;
let stopRequested = false;
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLbl.textContent = 'Generating\u2026';
else progressLbl.textContent = 'Queued\u2026';
} catch (err) {}
}, 1500);
});
}
function setGeneratingState(active) {
generateBtn.disabled = active;
endlessBtn.disabled = active;
stopBtn.classList.toggle('d-none', !active);
if (!active) progressCont.classList.add('d-none');
}
async function runOne(label) {
if (document.getElementById('lucky-dip').checked) applyLuckyDip();
progressCont.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLbl.textContent = label;
const fd = new FormData(form);
const resp = await fetch(form.action, {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
currentJobId = data.job_id;
progressLbl.textContent = 'Queued\u2026';
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
resultImg.src = jobResult.result.image_url;
resultImg.parentElement.classList.remove('d-none');
if (placeholder) placeholder.classList.add('d-none');
}
updateSeedFromResult(jobResult.result);
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
async function runLoop(endless) {
const total = endless ? Infinity : (parseInt(numInput.value) || 1);
stopRequested = false;
setGeneratingState(true);
let n = 0;
try {
while (!stopRequested && n < total) {
n++;
const lbl = endless ? `Generating #${n} (endless)...`
: total === 1 ? 'Starting...'
: `Generating ${n} / ${total}...`;
await runOne(lbl);
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
setGeneratingState(false);
}
}
form.addEventListener('submit', (e) => { e.preventDefault(); runLoop(false); });
endlessBtn.addEventListener('click', () => runLoop(true));
stopBtn.addEventListener('click', () => {
stopRequested = true;
progressLbl.textContent = 'Stopping after current image...';
});
// Random character buttons
document.querySelectorAll('.random-char-btn').forEach(btn => {
btn.addEventListener('click', () => {
randomizeSelect(btn.dataset.target);
});
});
document.getElementById('random-ckpt-btn').addEventListener('click', () => {
randomizeSelect('checkpoint');
});
document.getElementById('char_a').addEventListener('change', buildPromptPreview);
document.getElementById('char_b').addEventListener('change', buildPromptPreview);
document.getElementById('seed-clear-btn').addEventListener('click', () => {
document.getElementById('seed-input').value = '';
});
});
</script>
{% endblock %}

View File

@@ -71,6 +71,31 @@
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality">{{ extra_negative or '' }}</textarea> <textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality">{{ extra_negative or '' }}</textarea>
</div> </div>
{# Resolution override #}
{% set res = preset.data.get('resolution', {}) %}
<div class="mb-3">
<label class="form-label">Resolution Override</label>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="" data-h="">Preset Default</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1024" data-h="1024">1:1</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1152" data-h="896">4:3 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="896" data-h="1152">4:3 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1344" data-h="768">16:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="768" data-h="1344">16:9 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1280" data-h="800">16:10 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="800" data-h="1280">16:10 P</button>
</div>
<div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 small fw-semibold">W</label>
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
<span class="text-muted">&times;</span>
<label class="form-label mb-0 small fw-semibold">H</label>
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
</div>
</div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<div class="input-group input-group-sm mb-1"> <div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span> <span class="input-group-text">Seed</span>
@@ -131,7 +156,7 @@
<div class="mb-2"> <div class="mb-2">
<small class="text-muted fw-semibold d-block mb-1">Identity</small> <small class="text-muted fw-semibold d-block mb-1">Identity</small>
<div class="d-flex flex-wrap gap-1"> <div class="d-flex flex-wrap gap-1">
{% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %} {% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small"> <span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
<span>{{ k | replace('_', ' ') }}</span> <span>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(char_fields.identity.get(k, true)) }} {{ toggle_badge(char_fields.identity.get(k, true)) }}
@@ -156,7 +181,7 @@
<span class="badge bg-light text-dark border ms-1">outfit: {{ wd.get('outfit', 'default') }}</span> <span class="badge bg-light text-dark border ms-1">outfit: {{ wd.get('outfit', 'default') }}</span>
</small> </small>
<div class="d-flex flex-wrap gap-1"> <div class="d-flex flex-wrap gap-1">
{% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %} {% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small"> <span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
<span>{{ k | replace('_', ' ') }}</span> <span>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(wd.fields.get(k, true)) }} {{ toggle_badge(wd.fields.get(k, true)) }}
@@ -175,7 +200,7 @@
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
{% for section, label, field_key, field_keys in [ {% for section, label, field_key, field_keys in [
('outfit', 'Outfit', 'outfit_id', []), ('outfit', 'Outfit', 'outfit_id', []),
('action', 'Action', 'action_id', ['full_body','additional','head','eyes','arms','hands']), ('action', 'Action', 'action_id', ['base','head','upper_body','lower_body','hands','feet','additional']),
('style', 'Style', 'style_id', []), ('style', 'Style', 'style_id', []),
('scene', 'Scene', 'scene_id', ['background','foreground','furniture','colors','lighting','theme']), ('scene', 'Scene', 'scene_id', ['background','foreground','furniture','colors','lighting','theme']),
('detailer', 'Detailer', 'detailer_id', []), ('detailer', 'Detailer', 'detailer_id', []),
@@ -225,6 +250,21 @@
<div class="card-body py-2">{{ entity_badge(preset.data.get('checkpoint', {}).get('checkpoint_path')) }}</div> <div class="card-body py-2">{{ entity_badge(preset.data.get('checkpoint', {}).get('checkpoint_path')) }}</div>
</div> </div>
</div> </div>
{% set res = preset.data.get('resolution', {}) %}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-1"><small class="fw-semibold">Resolution</small></div>
<div class="card-body py-2">
{% if res.get('random', false) %}
<span class="badge bg-warning text-dark">Random</span>
{% elif res.get('width') %}
<span class="badge bg-info text-dark">{{ res.width }} &times; {{ res.height }}</span>
{% else %}
<span class="badge bg-secondary">Default</span>
{% endif %}
</div>
</div>
</div>
</div> </div>
<!-- Tags --> <!-- Tags -->
@@ -362,6 +402,20 @@ window._onEndlessResult = function(jobResult) {
} }
}; };
// Resolution preset buttons
document.querySelectorAll('.res-preset').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.res-preset').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
});
});
// JSON editor // JSON editor
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}"); initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
</script> </script>

View File

@@ -81,7 +81,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Identity Fields</label> <label class="form-label fw-semibold">Identity Fields</label>
<div class="row g-2"> <div class="row g-2">
{% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %} {% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
<div class="col-6 col-sm-4 col-md-4"> <div class="col-6 col-sm-4 col-md-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2"> <div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small> <small>{{ k | replace('_', ' ') }}</small>
@@ -114,7 +114,7 @@
value="{{ wd_cfg.get('outfit', 'default') }}" placeholder="default"> value="{{ wd_cfg.get('outfit', 'default') }}" placeholder="default">
</div> </div>
<div class="row g-2"> <div class="row g-2">
{% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %} {% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
<div class="col-6 col-sm-4"> <div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2"> <div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small> <small>{{ k | replace('_', ' ') }}</small>
@@ -144,7 +144,7 @@
</div> </div>
<label class="form-label fw-semibold">Fields</label> <label class="form-label fw-semibold">Fields</label>
<div class="row g-2"> <div class="row g-2">
{% for k in ['full_body','additional','head','eyes','arms','hands'] %} {% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
<div class="col-6 col-sm-4"> <div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2"> <div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small> <small>{{ k | replace('_', ' ') }}</small>
@@ -266,6 +266,40 @@
</div> </div>
</div> </div>
<!-- Resolution -->
{% set res = d.get('resolution', {}) %}
<div class="card mb-4">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<strong>Resolution</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="res_random" id="res_random" {% if res.get('random', false) %}checked{% endif %}>
<label class="form-check-label small" for="res_random">Random aspect ratio</label>
</div>
</div>
<div class="card-body" id="res-fields">
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1024" data-h="1024">1:1</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1152" data-h="896">4:3 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="896" data-h="1152">4:3 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1344" data-h="768">16:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="768" data-h="1344">16:9 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1280" data-h="800">16:10 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="800" data-h="1280">16:10 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1792" data-h="768">21:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="768" data-h="1792">21:9 P</button>
</div>
<div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 small fw-semibold">W</label>
<input type="number" class="form-control form-control-sm" name="res_width" id="res-width"
value="{{ res.get('width', 1024) }}" min="64" max="4096" step="64" style="width:88px">
<span class="text-muted">&times;</span>
<label class="form-label mb-0 small fw-semibold">H</label>
<input type="number" class="form-control form-control-sm" name="res_height" id="res-height"
value="{{ res.get('height', 1024) }}" min="64" max="4096" step="64" style="width:88px">
</div>
</div>
</div>
<div class="d-flex gap-2 pb-4"> <div class="d-flex gap-2 pb-4">
<button type="submit" class="btn btn-primary">Save Preset</button> <button type="submit" class="btn btn-primary">Save Preset</button>
<a href="{{ url_for('preset_detail', slug=preset.slug) }}" class="btn btn-outline-secondary">Cancel</a> <a href="{{ url_for('preset_detail', slug=preset.slug) }}" class="btn btn-outline-secondary">Cancel</a>
@@ -274,3 +308,45 @@
</div> </div>
</form> </form>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
// Resolution preset buttons
document.querySelectorAll('.res-preset').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.res-preset').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
});
});
// Highlight the matching preset button on load
(function() {
const w = document.getElementById('res-width').value;
const h = document.getElementById('res-height').value;
document.querySelectorAll('.res-preset').forEach(btn => {
if (btn.dataset.w === w && btn.dataset.h === h) {
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
}
});
// Toggle resolution fields visibility based on random checkbox
const randomCb = document.getElementById('res_random');
const resFields = document.getElementById('res-fields');
function toggleResFields() {
resFields.querySelectorAll('input, button.res-preset').forEach(el => {
el.disabled = randomCb.checked;
});
resFields.style.opacity = randomCb.checked ? '0.5' : '1';
}
randomCb.addEventListener('change', toggleResFields);
toggleResFields();
})();
</script>
{% endblock %}

314
templates/quick.html Normal file
View File

@@ -0,0 +1,314 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-5">
<div id="progress-container" class="mb-3 d-none">
<label id="progress-label" class="form-label">Generating...</label>
<div class="progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-primary text-white">Quick Generator</div>
<div class="card-body">
<form id="quick-form">
<!-- Controls bar -->
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
<button type="submit" class="btn btn-primary" id="generate-btn" data-requires="comfyui">Generate</button>
<input type="number" id="num-images" value="1" min="1" max="999"
class="form-control form-control-sm" style="width:65px" title="Number of images">
<div class="input-group input-group-sm" style="width:180px">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="button" class="btn btn-outline-warning" id="endless-btn">Endless</button>
<button type="button" class="btn btn-danger d-none" id="stop-btn">Stop</button>
</div>
<!-- Preset selector -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="preset-select" class="form-label mb-0">Preset</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-preset-btn">Random</button>
</div>
<select class="form-select" id="preset-select" required>
<option value="" disabled selected>Select a preset...</option>
{% for preset in presets %}
<option value="{{ preset.slug }}"
data-generate-url="{{ url_for('generate_preset_image', slug=preset.slug) }}"
data-detail-url="{{ url_for('preset_detail', slug=preset.slug) }}">{{ preset.name }}</option>
{% endfor %}
</select>
</div>
<!-- Checkpoint override -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="checkpoint_override" class="form-label mb-0">Checkpoint Override</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button>
</div>
<select class="form-select" id="checkpoint_override" name="checkpoint_override">
<option value="">Use preset default</option>
{% for ckpt in checkpoints %}
<option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt }}</option>
{% endfor %}
</select>
</div>
<!-- Resolution override -->
<div class="mb-3">
<label class="form-label">Resolution Override</label>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-sm btn-secondary res-preset" data-w="" data-h="">Preset Default</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1024" data-h="1024">1:1</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1152" data-h="896">4:3 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="896" data-h="1152">4:3 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1344" data-h="768">16:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="768" data-h="1344">16:9 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1280" data-h="800">16:10 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="800" data-h="1280">16:10 P</button>
</div>
<div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 small fw-semibold">W</label>
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
<span class="text-muted">&times;</span>
<label class="form-label mb-0 small fw-semibold">H</label>
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
</div>
</div>
<!-- Additional prompts -->
<div class="mb-3">
<label for="extra_positive" class="form-label">Additional Positive Prompt</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality"></textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative Prompt</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality"></textarea>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card mb-3">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<span>Result</span>
<a href="#" id="preset-link" class="btn btn-sm btn-outline-light d-none">View Preset</a>
</div>
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container">
<div class="text-center text-muted" id="placeholder-text">
<p>Select a preset and click Generate</p>
</div>
<div class="img-container w-100 h-100 d-none">
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
</div>
</div>
</div>
<!-- Generated images gallery -->
<div class="card">
<div class="card-header py-2"><strong>Generated Images</strong></div>
<div class="card-body py-2">
<div class="row g-2" id="generated-images"></div>
<p id="no-images-msg" class="text-muted small mt-2">No generated images yet.</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('quick-form');
const presetSelect = document.getElementById('preset-select');
const progressBar = document.getElementById('progress-bar');
const progressCont = document.getElementById('progress-container');
const progressLbl = document.getElementById('progress-label');
const generateBtn = document.getElementById('generate-btn');
const endlessBtn = document.getElementById('endless-btn');
const stopBtn = document.getElementById('stop-btn');
const numInput = document.getElementById('num-images');
const resultImg = document.getElementById('result-img');
const placeholder = document.getElementById('placeholder-text');
const presetLink = document.getElementById('preset-link');
let stopRequested = false;
function getSelectedOption() {
return presetSelect.options[presetSelect.selectedIndex];
}
function getGenerateUrl() {
const opt = getSelectedOption();
return opt ? opt.dataset.generateUrl : null;
}
// Update preset detail link when selection changes
presetSelect.addEventListener('change', () => {
const opt = getSelectedOption();
if (opt && opt.dataset.detailUrl) {
presetLink.href = opt.dataset.detailUrl;
presetLink.classList.remove('d-none');
} else {
presetLink.classList.add('d-none');
}
});
// Random preset
document.getElementById('random-preset-btn').addEventListener('click', () => {
const opts = Array.from(presetSelect.options).filter(o => o.value);
if (opts.length) {
presetSelect.value = opts[Math.floor(Math.random() * opts.length)].value;
presetSelect.dispatchEvent(new Event('change'));
}
});
// Resolution preset buttons
document.querySelectorAll('.res-preset').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.res-preset').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
});
});
// Random checkpoint
document.getElementById('random-ckpt-btn').addEventListener('click', () => {
const sel = document.getElementById('checkpoint_override');
const opts = Array.from(sel.options).filter(o => o.value);
if (opts.length) sel.value = opts[Math.floor(Math.random() * opts.length)].value;
});
// Seed clear
document.getElementById('seed-clear-btn').addEventListener('click', () => {
document.getElementById('seed-input').value = '';
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLbl.textContent = 'Generating…';
else progressLbl.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
function setGeneratingState(active) {
generateBtn.disabled = active;
endlessBtn.disabled = active;
presetSelect.disabled = active;
stopBtn.classList.toggle('d-none', !active);
if (!active) progressCont.classList.add('d-none');
}
function addImageToGallery(imageUrl, relativePath) {
const img = document.createElement('img');
img.src = imageUrl;
img.className = 'img-fluid rounded';
img.style.cursor = 'pointer';
img.addEventListener('click', () => {
resultImg.src = imageUrl;
resultImg.parentElement.classList.remove('d-none');
if (placeholder) placeholder.classList.add('d-none');
});
const col = document.createElement('div');
col.className = 'col-4 col-md-3';
col.appendChild(img);
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
}
async function runOne(label) {
const url = getGenerateUrl();
if (!url) throw new Error('No preset selected');
progressCont.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLbl.textContent = label;
const fd = new FormData(form);
fd.set('action', 'preview');
const resp = await fetch(url, {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
progressLbl.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result && jobResult.result.image_url) {
resultImg.src = jobResult.result.image_url;
resultImg.parentElement.classList.remove('d-none');
if (placeholder) placeholder.classList.add('d-none');
addImageToGallery(jobResult.result.image_url, jobResult.result.relative_path);
}
updateSeedFromResult(jobResult.result);
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
async function runLoop(endless) {
if (!presetSelect.value) { alert('Select a preset first.'); return; }
const total = endless ? Infinity : (parseInt(numInput.value) || 1);
stopRequested = false;
setGeneratingState(true);
let n = 0;
try {
while (!stopRequested && n < total) {
n++;
const presetName = getSelectedOption()?.text || 'Preset';
const lbl = endless ? `${presetName} — #${n} (endless)...`
: total === 1 ? `${presetName} — Starting...`
: `${presetName}${n} / ${total}...`;
await runOne(lbl);
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
setGeneratingState(false);
}
}
form.addEventListener('submit', (e) => { e.preventDefault(); runLoop(false); });
endlessBtn.addEventListener('click', () => runLoop(true));
stopBtn.addEventListener('click', () => {
stopRequested = true;
progressLbl.textContent = 'Stopping after current image...';
});
// Pre-select from URL param ?preset=slug
const urlPreset = new URLSearchParams(window.location.search).get('preset');
if (urlPreset) {
presetSelect.value = urlPreset;
presetSelect.dispatchEvent(new Event('change'));
}
});
</script>
{% endblock %}

View File

@@ -125,7 +125,7 @@
try { try {
const genResp = await fetch(`/scene/${scene.slug}/generate`, { const genResp = await fetch(`/scene/${scene.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }), body: new URLSearchParams({ action: 'replace', character_slug: '' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();

View File

@@ -11,8 +11,9 @@ _LORA_DEFAULTS = {
'detailers': '/ImageModels/lora/Illustrious/Detailers', 'detailers': '/ImageModels/lora/Illustrious/Detailers',
} }
_IDENTITY_KEYS = ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra'] _BODY_GROUP_KEYS = ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']
_WARDROBE_KEYS = ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories'] _IDENTITY_KEYS = _BODY_GROUP_KEYS
_WARDROBE_KEYS = _BODY_GROUP_KEYS
def allowed_file(filename): def allowed_file(filename):