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 presets
from routes import generator
from routes import quick
from routes import multi_char
from routes import gallery
from routes import strengths
from routes import transfer
@@ -28,6 +30,8 @@ def register_routes(app):
looks.register_routes(app)
presets.register_routes(app)
generator.register_routes(app)
quick.register_routes(app)
multi_char.register_routes(app)
gallery.register_routes(app)
strengths.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
# 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)]
# 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'] = {
'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)
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
# Add identity fields
for key in ['base_specs', 'hair', 'eyes']:
for key in ['base', 'head']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
# Add wardrobe fields
from utils import _WARDROBE_KEYS
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):
selected_fields.append(f'wardrobe::{key}')
@@ -261,11 +263,12 @@ def register_routes(app):
action_data = action_obj.data.get('action', {})
# 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)]
# 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 = {
'character_id': action_obj.action_id,
@@ -312,7 +315,7 @@ def register_routes(app):
# 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)
if val:
# Remove 1girl/solo
@@ -320,8 +323,9 @@ def register_routes(app):
extra_parts.append(val)
# Wardrobe (active outfit)
from utils import _WARDROBE_KEYS
wardrobe = extra_char.get_active_wardrobe()
for key in ['top', 'headwear', 'legwear', 'footwear', 'accessories']:
for key in _WARDROBE_KEYS:
val = wardrobe.get(key)
if val:
extra_parts.append(val)
@@ -531,8 +535,8 @@ def register_routes(app):
"action_id": safe_slug,
"action_name": name,
"action": {
"full_body": "", "head": "", "eyes": "", "arms": "", "hands": "",
"torso": "", "pelvis": "", "legs": "", "feet": "", "additional": ""
"base": "", "head": "", "upper_body": "", "lower_body": "",
"hands": "", "feet": "", "additional": ""
},
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
"tags": []

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ def register_routes(app):
else:
# Auto-include essential character fields (minimal set for batch/default generation)
selected_fields = []
for key in ['base_specs', 'hair', 'eyes']:
for key in ['base', 'head']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
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.job_queue import _enqueue_job, _make_finalize
from services.file_io import get_available_checkpoints
from services.comfyui import get_loaded_checkpoint
logger = logging.getLogger('gaze')
@@ -25,6 +26,12 @@ def register_routes(app):
if not checkpoints:
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':
char_slug = request.form.get('character')
checkpoint = request.form.get('checkpoint')
@@ -63,9 +70,17 @@ def register_routes(app):
if extras:
combined = f"{combined}, {extras}"
if custom_positive:
combined = f"{combined}, {custom_positive}"
combined = f"{custom_positive}, {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
seed_val = request.form.get('seed', '').strip()
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,
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'])
def generator_preview_prompt():
@@ -134,6 +149,6 @@ def register_routes(app):
if extras:
combined = f"{combined}, {extras}"
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_name": character_name,
"identity": {
"base_specs": lora_data.get('lora_triggers', ''),
"hair": "",
"eyes": "",
"base": lora_data.get('lora_triggers', ''),
"head": "",
"upper_body": "",
"lower_body": "",
"hands": "",
"arms": "",
"torso": "",
"pelvis": "",
"legs": "",
"feet": "",
"extra": ""
"additional": ""
},
"defaults": {
"expression": "",
@@ -373,14 +370,13 @@ Character ID: {character_slug}"""
"scene": ""
},
"wardrobe": {
"full_body": "",
"headwear": "",
"top": "",
"bottom": "",
"legwear": "",
"footwear": "",
"base": "",
"head": "",
"upper_body": "",
"lower_body": "",
"hands": "",
"accessories": ""
"feet": "",
"additional": ""
},
"styles": {
"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
# that includes identity + wardrobe + name + lora triggers, but NOT character
# 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):
selected_fields.append(f'identity::{key}')
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):
selected_fields.append(f'wardrobe::{key}')
selected_fields.append('special::name')
@@ -456,14 +457,13 @@ def register_routes(app):
# Ensure required fields exist
if 'wardrobe' not in outfit_data:
outfit_data['wardrobe'] = {
"full_body": "",
"headwear": "",
"top": "",
"bottom": "",
"legwear": "",
"footwear": "",
"base": "",
"head": "",
"upper_body": "",
"lower_body": "",
"hands": "",
"accessories": ""
"feet": "",
"additional": ""
}
if 'lora' not in outfit_data:
outfit_data['lora'] = {
@@ -484,14 +484,13 @@ def register_routes(app):
"outfit_id": safe_slug,
"outfit_name": name,
"wardrobe": {
"full_body": "",
"headwear": "",
"top": "",
"bottom": "",
"legwear": "",
"footwear": "",
"base": "",
"head": "",
"upper_body": "",
"lower_body": "",
"hands": "",
"accessories": ""
"feet": "",
"additional": ""
},
"lora": {
"lora_name": "",

View File

@@ -70,7 +70,13 @@ def register_routes(app):
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_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
checkpoint_override = request.form.get('checkpoint_override', '').strip()
if checkpoint_override:
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint_override).first()
ckpt_path = checkpoint_override
ckpt_data = ckpt_obj.data if ckpt_obj else None
else:
preset_ckpt = ckpt_cfg.get('checkpoint_path')
if preset_ckpt == 'random':
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first()
@@ -107,7 +113,8 @@ def register_routes(app):
extras_parts = []
if action_obj:
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)
if val_cfg == 'random':
val_cfg = random.choice([True, False])
@@ -172,6 +179,23 @@ def register_routes(app):
seed_val = request.form.get('seed', '').strip()
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, character, prompts,
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,
look=look_obj,
fixed_seed=fixed_seed,
width=gen_width,
height=gen_height,
)
label = f"Preset: {preset.name} {action}"
@@ -258,13 +284,13 @@ def register_routes(app):
'use_lora': request.form.get('char_use_lora') == 'on',
'fields': {
'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'))
for k in ['expression', 'pose', 'scene']},
'wardrobe': {
'outfit': request.form.get('wardrobe_outfit', 'default') or 'default',
'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')),
'use_lora': request.form.get('action_use_lora') == 'on',
'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')),
'use_lora': request.form.get('style_use_lora') == 'on'},
'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'},
'look': {'look_id': _entity_id(request.form.get('look_id'))},
'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()],
}
@@ -399,20 +430,21 @@ def register_routes(app):
preset_data = {
'character': {'character_id': 'random', 'use_lora': True,
'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']},
'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},
'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},
'scene': {'scene_id': None, 'use_lora': True,
'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}},
'detailer': {'detailer_id': None, 'use_lora': True},
'look': {'look_id': None},
'checkpoint': {'checkpoint_path': None},
'resolution': {'width': 1024, 'height': 1024, 'random': False},
'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:
# Auto-include essential character fields (minimal set for batch/default generation)
selected_fields = []
for key in ['base_specs', 'hair', 'eyes']:
for key in ['base', 'head']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
selected_fields.append('special::name')

View File

@@ -78,11 +78,11 @@ def register_routes(app):
if character:
identity = character.data.get('identity', {})
defaults = character.data.get('defaults', {})
char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'),
identity.get('eyes'), defaults.get('expression')] if v]
face_parts = [v for v in [identity.get('hair'), identity.get('eyes'),
char_parts = [v for v in [identity.get('base'), identity.get('head'),
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
return {
'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_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional']
pose_parts = [action_data.get(k, '') for k in pose_fields if action_data.get(k)]
expr_parts = [action_data.get(k, '') for k in ['head', 'eyes'] if action_data.get(k)]
from utils import _BODY_GROUP_KEYS
pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
char_parts = []
face_parts = list(expr_parts)
hand_parts = [action_data.get('hands', '')] if action_data.get('hands') else []
if character:
identity = character.data.get('identity', {})
char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'),
identity.get('eyes')] if v]
face_parts = [v for v in [identity.get('hair'), identity.get('eyes')] + expr_parts if v]
char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
face_parts = [v for v in [identity.get('head')] + expr_parts if v]
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
return {
'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]
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)
if entity_str:
base['main'] = f"{base['main']}, {entity_str}" if base['main'] else entity_str
if action is not None:
action_data = action.data.get('action', {})
action_parts = [action_data.get(k, '') for k in
['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional', 'head', 'eyes']
from utils import _BODY_GROUP_KEYS
action_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS
if action_data.get(k)]
action_str = ', '.join(action_parts)
if action_str:

View File

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

View File

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

View File

@@ -6,6 +6,22 @@ from flask import current_app
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):
"""Check if the desired checkpoint is loaded in ComfyUI, and force reload if not."""
if not checkpoint_path:

View File

@@ -1,6 +1,6 @@
import re
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):
@@ -114,15 +114,21 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
style_data = data.get('style', {})
participants = data.get('participants', {})
# Pre-calculate Hand/Glove priority
# Priority: wardrobe gloves > wardrobe hands (outfit) > identity hands (character)
hand_val = ""
if wardrobe.get('gloves') and is_selected('wardrobe', 'gloves'):
hand_val = wardrobe.get('gloves')
elif wardrobe.get('hands') and is_selected('wardrobe', 'hands'):
hand_val = wardrobe.get('hands')
elif identity.get('hands') and is_selected('identity', 'hands'):
hand_val = identity.get('hands')
# Helper: collect selected values from identity + wardrobe for a given group key
def _group_val(key):
parts = []
id_val = identity.get(key, '')
wd_val = wardrobe.get(key, '')
if id_val and is_selected('identity', key):
val = id_val
# Filter out conflicting tags from base if participants data is present
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
parts = []
@@ -131,12 +137,10 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
if participants:
if participants.get('solo_focus') == 'true':
parts.append('(solo focus:1.2)')
orientation = participants.get('orientation', '')
if orientation:
parts.extend(parse_orientation(orientation))
else:
# Default behavior
parts.append("(solo:1.2)")
# 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'):
parts.append(char_tag)
for key in ['base_specs', 'hair', 'eyes', 'extra']:
val = identity.get(key)
if val and is_selected('identity', key):
# Filter out conflicting tags if participants data is present
if participants and key == 'base_specs':
# Remove 1girl, 1boy, solo, etc.
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
# Add all body groups to main prompt
for key in _BODY_GROUP_KEYS:
val = _group_val(key)
if val:
parts.append(val)
# 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):
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
char_aesthetic = data.get('styles', {}).get('aesthetic')
if char_aesthetic and is_selected('styles', 'aesthetic'):
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'):
parts.append(f"by {style_data['artist_name']}")
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'):
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 = []
if char_tag and is_selected('special', 'name'): face_parts.append(char_tag)
if identity.get('eyes') and is_selected('identity', 'eyes'): face_parts.append(identity.get('eyes'))
if defaults.get('expression') and is_selected('defaults', 'expression'): face_parts.append(defaults.get('expression'))
if wardrobe.get('headwear') and is_selected('wardrobe', 'headwear'): face_parts.append(wardrobe.get('headwear'))
if char_tag and is_selected('special', 'name'):
face_parts.append(char_tag)
head_val = _group_val('head')
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
if action_data.get('head') and is_selected('action', 'head'): face_parts.append(action_data.get('head'))
if action_data.get('eyes') and is_selected('action', 'eyes'): face_parts.append(action_data.get('eyes'))
# 3. Hand Prompt: hands group + action hands
hand_parts = []
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
hand_parts = [hand_val] if hand_val else []
if action_data.get('arms') and is_selected('action', 'arms'): hand_parts.append(action_data.get('arms'))
if action_data.get('hands') and is_selected('action', 'hands'): hand_parts.append(action_data.get('hands'))
# 4. Feet Prompt: feet group + action feet
feet_parts = []
feet_val = _group_val('feet')
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 {
"main": _dedup_tags(", ".join(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'):
parts.append(lora['lora_triggers'])
parts.extend(data.get('tags', []))
for key in ['full_body', 'additional']:
for key in _BODY_GROUP_KEYS:
val = data.get('action', {}).get(key)
if val:
parts.append(val)
@@ -228,7 +292,7 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
for outfit in outfits:
data = outfit.data
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)
if val:
parts.append(val)

View File

@@ -153,14 +153,13 @@ def ensure_default_outfit():
"outfit_id": "default",
"outfit_name": "Default",
"wardrobe": {
"full_body": "",
"headwear": "",
"top": "",
"bottom": "",
"legwear": "",
"footwear": "",
"base": "",
"head": "",
"upper_body": "",
"lower_body": "",
"hands": "",
"accessories": ""
"feet": "",
"additional": ""
},
"lora": {
"lora_name": "",
@@ -360,7 +359,8 @@ def _resolve_preset_fields(preset_data):
char_cfg = preset_data.get('character', {})
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)
if val == 'random':
val = random.choice([True, False])
@@ -375,7 +375,7 @@ def _resolve_preset_fields(preset_data):
selected.append(f'defaults::{key}')
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)
if val == 'random':
val = random.choice([True, False])

View File

@@ -9,6 +9,11 @@ from services.prompts import _cross_dedup_prompts
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):
"""Log the final assembled ComfyUI prompts in a consistent, readable block."""
@@ -17,7 +22,7 @@ def _log_workflow_prompts(label, workflow):
lora_details = []
# 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:
name = workflow[node_id]["inputs"].get("lora_name", "")
if name:
@@ -41,11 +46,18 @@ def _log_workflow_prompts(label, workflow):
# Extract adetailer information
adetailer_info = []
# Single-char mode: FaceDetailer nodes 11 + 13
for node_id, node_name in [("11", "Face"), ("13", "Hand")]:
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', '?')}")
# 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', '')
hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '')
@@ -95,6 +107,12 @@ def _log_workflow_prompts(label, workflow):
if 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)
logger.info("\n%s", "\n".join(lines))
@@ -119,8 +137,8 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
if scheduler and '3' in workflow:
workflow['3']['inputs']['scheduler'] = scheduler
# Face/hand detailers (nodes 11, 13)
for node_id in ['11', '13']:
# Face/hand detailers (nodes 11, 13) + multi-char SEGS detailers
for node_id in ['11', '13'] + _SEGS_DETAILER_NODES:
if node_id in workflow:
if steps:
workflow[node_id]['inputs']['steps'] = int(steps)
@@ -131,9 +149,9 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
if 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:
for node_id in ['6', '14', '15']:
for node_id in ['6', '14', '15'] + _SEGS_PROMPT_NODES:
if node_id in workflow:
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:
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:
workflow[node_id]['inputs']['vae'] = ['21', 0]
@@ -187,12 +205,246 @@ def _get_default_checkpoint():
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
workflow["6"]["inputs"]["text"] = workflow["6"]["inputs"]["text"].replace("{{POSITIVE_PROMPT}}", prompts["main"])
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:
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]
logger.debug("Style/Detailer LoRA: %s @ %s", style_lora_name, _w19)
# Apply connections to all model/clip consumers
workflow["3"]["inputs"]["model"] = model_source
workflow["11"]["inputs"]["model"] = model_source
workflow["13"]["inputs"]["model"] = model_source
# Second character LoRA (Node 20) - for multi-character generation
if character_b:
char_b_lora_data = character_b.data.get('lora', {})
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
workflow["7"]["inputs"]["clip"] = clip_source
workflow["11"]["inputs"]["clip"] = clip_source
workflow["13"]["inputs"]["clip"] = clip_source
workflow["14"]["inputs"]["clip"] = clip_source
workflow["15"]["inputs"]["clip"] = clip_source
# 3b. Multi-char: inject per-character SEGS detailers (replaces node 11/14)
if character_b:
_inject_multi_char_detailers(workflow, prompts, model_source, clip_source)
# Apply connections to all model/clip consumers (conditional on node existence)
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)
gen_seed = fixed_seed if fixed_seed is not None else random.randint(1, 10**15)
workflow["3"]["inputs"]["seed"] = gen_seed
if "11" in workflow: workflow["11"]["inputs"]["seed"] = gen_seed
if "13" in workflow: workflow["13"]["inputs"]["seed"] = gen_seed
for nid in ["3", "11", "13"] + _SEGS_DETAILER_NODES:
if nid in workflow:
workflow[nid]["inputs"]["seed"] = gen_seed
# 5. Set image dimensions
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
sampler_name = workflow["3"]["inputs"].get("sampler_name")
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 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 {
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>
</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"
name="override_prompt" rows="5"
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
<div class="form-text" id="preview-status"></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 -->
<div class="mb-3">
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
@@ -246,7 +263,9 @@
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 = '';
}
@@ -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 ---
async function buildPromptPreview() {
const charVal = document.getElementById('character').value;
@@ -288,7 +352,12 @@
status.textContent = 'Error: ' + data.error;
} else {
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) {
status.textContent = 'Request failed.';
@@ -297,7 +366,9 @@
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
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 = '';
});

View File

@@ -4,6 +4,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Character Library</h2>
<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="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">

View File

@@ -25,8 +25,9 @@
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
<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="/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="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<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>
</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="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
@@ -131,7 +156,7 @@
<div class="mb-2">
<small class="text-muted fw-semibold d-block mb-1">Identity</small>
<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>{{ k | replace('_', ' ') }}</span>
{{ 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>
</small>
<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>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(wd.fields.get(k, true)) }}
@@ -175,7 +200,7 @@
<div class="row g-2 mb-3">
{% for section, label, field_key, field_keys in [
('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', []),
('scene', 'Scene', 'scene_id', ['background','foreground','furniture','colors','lighting','theme']),
('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>
</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>
<!-- 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
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
</script>

View File

@@ -81,7 +81,7 @@
<div class="mb-3">
<label class="form-label fw-semibold">Identity Fields</label>
<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="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
@@ -114,7 +114,7 @@
value="{{ wd_cfg.get('outfit', 'default') }}" placeholder="default">
</div>
<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="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
@@ -144,7 +144,7 @@
</div>
<label class="form-label fw-semibold">Fields</label>
<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="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
@@ -266,6 +266,40 @@
</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">
<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>
@@ -274,3 +308,45 @@
</div>
</form>
{% 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 {
const genResp = await fetch(`/scene/${scene.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
body: new URLSearchParams({ action: 'replace', character_slug: '' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();

View File

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