Updated generation pages.
This commit is contained in:
164
migrate_field_groups.py
Normal file
164
migrate_field_groups.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']}
|
||||
|
||||
@@ -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
153
routes/multi_char.py
Normal 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'],
|
||||
}
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
25
routes/quick.py
Normal 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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', []),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
547
templates/multi_char.html
Normal 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)">×</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 & 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">×</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 %}
|
||||
@@ -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">×</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 }} × {{ 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>
|
||||
|
||||
@@ -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">×</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
314
templates/quick.html
Normal 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)">×</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">×</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 %}
|
||||
@@ -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();
|
||||
|
||||
5
utils.py
5
utils.py
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user