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 looks
|
||||||
from routes import presets
|
from routes import presets
|
||||||
from routes import generator
|
from routes import generator
|
||||||
|
from routes import quick
|
||||||
|
from routes import multi_char
|
||||||
from routes import gallery
|
from routes import gallery
|
||||||
from routes import strengths
|
from routes import strengths
|
||||||
from routes import transfer
|
from routes import transfer
|
||||||
@@ -28,6 +30,8 @@ def register_routes(app):
|
|||||||
looks.register_routes(app)
|
looks.register_routes(app)
|
||||||
presets.register_routes(app)
|
presets.register_routes(app)
|
||||||
generator.register_routes(app)
|
generator.register_routes(app)
|
||||||
|
quick.register_routes(app)
|
||||||
|
multi_char.register_routes(app)
|
||||||
gallery.register_routes(app)
|
gallery.register_routes(app)
|
||||||
strengths.register_routes(app)
|
strengths.register_routes(app)
|
||||||
transfer.register_routes(app)
|
transfer.register_routes(app)
|
||||||
|
|||||||
@@ -213,11 +213,12 @@ def register_routes(app):
|
|||||||
combined_data['participants'] = action_obj.data.get('participants', {}) # Add participants
|
combined_data['participants'] = action_obj.data.get('participants', {}) # Add participants
|
||||||
|
|
||||||
# Aggregate pose-related fields into 'pose'
|
# Aggregate pose-related fields into 'pose'
|
||||||
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet']
|
pose_fields = ['base', 'upper_body', 'lower_body', 'hands', 'feet']
|
||||||
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
|
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
|
||||||
|
|
||||||
# Aggregate expression-related fields into 'expression'
|
# Aggregate expression-related fields into 'expression'
|
||||||
expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)]
|
expression_parts = [action_data.get('head', '')]
|
||||||
|
expression_parts = [p for p in expression_parts if p]
|
||||||
|
|
||||||
combined_data['defaults'] = {
|
combined_data['defaults'] = {
|
||||||
'pose': ", ".join(pose_parts),
|
'pose': ", ".join(pose_parts),
|
||||||
@@ -245,12 +246,13 @@ def register_routes(app):
|
|||||||
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
|
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
|
||||||
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
|
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
|
||||||
# Add identity fields
|
# Add identity fields
|
||||||
for key in ['base_specs', 'hair', 'eyes']:
|
for key in ['base', 'head']:
|
||||||
if character.data.get('identity', {}).get(key):
|
if character.data.get('identity', {}).get(key):
|
||||||
selected_fields.append(f'identity::{key}')
|
selected_fields.append(f'identity::{key}')
|
||||||
# Add wardrobe fields
|
# Add wardrobe fields
|
||||||
|
from utils import _WARDROBE_KEYS
|
||||||
wardrobe = character.get_active_wardrobe()
|
wardrobe = character.get_active_wardrobe()
|
||||||
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']:
|
for key in _WARDROBE_KEYS:
|
||||||
if wardrobe.get(key):
|
if wardrobe.get(key):
|
||||||
selected_fields.append(f'wardrobe::{key}')
|
selected_fields.append(f'wardrobe::{key}')
|
||||||
|
|
||||||
@@ -261,11 +263,12 @@ def register_routes(app):
|
|||||||
action_data = action_obj.data.get('action', {})
|
action_data = action_obj.data.get('action', {})
|
||||||
|
|
||||||
# Aggregate pose-related fields into 'pose'
|
# Aggregate pose-related fields into 'pose'
|
||||||
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet']
|
pose_fields = ['base', 'upper_body', 'lower_body', 'hands', 'feet']
|
||||||
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
|
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
|
||||||
|
|
||||||
# Aggregate expression-related fields into 'expression'
|
# Aggregate expression-related fields into 'expression'
|
||||||
expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)]
|
expression_parts = [action_data.get('head', '')]
|
||||||
|
expression_parts = [p for p in expression_parts if p]
|
||||||
|
|
||||||
combined_data = {
|
combined_data = {
|
||||||
'character_id': action_obj.action_id,
|
'character_id': action_obj.action_id,
|
||||||
@@ -312,7 +315,7 @@ def register_routes(app):
|
|||||||
|
|
||||||
# Identity
|
# Identity
|
||||||
ident = extra_char.data.get('identity', {})
|
ident = extra_char.data.get('identity', {})
|
||||||
for key in ['base_specs', 'hair', 'eyes', 'extra']:
|
for key in ['base', 'head', 'additional']:
|
||||||
val = ident.get(key)
|
val = ident.get(key)
|
||||||
if val:
|
if val:
|
||||||
# Remove 1girl/solo
|
# Remove 1girl/solo
|
||||||
@@ -320,8 +323,9 @@ def register_routes(app):
|
|||||||
extra_parts.append(val)
|
extra_parts.append(val)
|
||||||
|
|
||||||
# Wardrobe (active outfit)
|
# Wardrobe (active outfit)
|
||||||
|
from utils import _WARDROBE_KEYS
|
||||||
wardrobe = extra_char.get_active_wardrobe()
|
wardrobe = extra_char.get_active_wardrobe()
|
||||||
for key in ['top', 'headwear', 'legwear', 'footwear', 'accessories']:
|
for key in _WARDROBE_KEYS:
|
||||||
val = wardrobe.get(key)
|
val = wardrobe.get(key)
|
||||||
if val:
|
if val:
|
||||||
extra_parts.append(val)
|
extra_parts.append(val)
|
||||||
@@ -531,8 +535,8 @@ def register_routes(app):
|
|||||||
"action_id": safe_slug,
|
"action_id": safe_slug,
|
||||||
"action_name": name,
|
"action_name": name,
|
||||||
"action": {
|
"action": {
|
||||||
"full_body": "", "head": "", "eyes": "", "arms": "", "hands": "",
|
"base": "", "head": "", "upper_body": "", "lower_body": "",
|
||||||
"torso": "", "pelvis": "", "legs": "", "feet": "", "additional": ""
|
"hands": "", "feet": "", "additional": ""
|
||||||
},
|
},
|
||||||
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
|
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
|
||||||
"tags": []
|
"tags": []
|
||||||
|
|||||||
@@ -295,14 +295,13 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
|
|||||||
# Ensure required fields
|
# Ensure required fields
|
||||||
if 'wardrobe' not in outfit_data:
|
if 'wardrobe' not in outfit_data:
|
||||||
outfit_data['wardrobe'] = {
|
outfit_data['wardrobe'] = {
|
||||||
"full_body": "",
|
"base": "",
|
||||||
"headwear": "",
|
"head": "",
|
||||||
"top": "",
|
"upper_body": "",
|
||||||
"bottom": "",
|
"lower_body": "",
|
||||||
"legwear": "",
|
|
||||||
"footwear": "",
|
|
||||||
"hands": "",
|
"hands": "",
|
||||||
"accessories": ""
|
"feet": "",
|
||||||
|
"additional": ""
|
||||||
}
|
}
|
||||||
if 'lora' not in outfit_data:
|
if 'lora' not in outfit_data:
|
||||||
outfit_data['lora'] = {
|
outfit_data['lora'] = {
|
||||||
@@ -392,16 +391,13 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
"character_id": safe_slug,
|
"character_id": safe_slug,
|
||||||
"character_name": name,
|
"character_name": name,
|
||||||
"identity": {
|
"identity": {
|
||||||
"base_specs": prompt,
|
"base": prompt,
|
||||||
"hair": "",
|
"head": "",
|
||||||
"eyes": "",
|
"upper_body": "",
|
||||||
|
"lower_body": "",
|
||||||
"hands": "",
|
"hands": "",
|
||||||
"arms": "",
|
|
||||||
"torso": "",
|
|
||||||
"pelvis": "",
|
|
||||||
"legs": "",
|
|
||||||
"feet": "",
|
"feet": "",
|
||||||
"extra": ""
|
"additional": ""
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"expression": "",
|
"expression": "",
|
||||||
@@ -631,8 +627,8 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
|
|
||||||
# Create new outfit (copy from default as template)
|
# Create new outfit (copy from default as template)
|
||||||
default_outfit = wardrobe.get('default', {
|
default_outfit = wardrobe.get('default', {
|
||||||
'headwear': '', 'top': '', 'legwear': '',
|
'base': '', 'head': '', 'upper_body': '', 'lower_body': '',
|
||||||
'footwear': '', 'hands': '', 'accessories': ''
|
'hands': '', 'feet': '', 'additional': ''
|
||||||
})
|
})
|
||||||
wardrobe[safe_name] = default_outfit.copy()
|
wardrobe[safe_name] = default_outfit.copy()
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ def register_routes(app):
|
|||||||
combined_data = character.data.copy()
|
combined_data = character.data.copy()
|
||||||
combined_data['character_id'] = character.character_id
|
combined_data['character_id'] = character.character_id
|
||||||
selected_fields = []
|
selected_fields = []
|
||||||
for key in ['base_specs', 'hair', 'eyes']:
|
for key in ['base', 'head']:
|
||||||
if character.data.get('identity', {}).get(key):
|
if character.data.get('identity', {}).get(key):
|
||||||
selected_fields.append(f'identity::{key}')
|
selected_fields.append(f'identity::{key}')
|
||||||
selected_fields.append('special::name')
|
selected_fields.append('special::name')
|
||||||
wardrobe = character.get_active_wardrobe()
|
wardrobe = character.get_active_wardrobe()
|
||||||
for key in ['full_body', 'top', 'bottom']:
|
for key in ['base', 'upper_body', 'lower_body']:
|
||||||
if wardrobe.get(key):
|
if wardrobe.get(key):
|
||||||
selected_fields.append(f'wardrobe::{key}')
|
selected_fields.append(f'wardrobe::{key}')
|
||||||
prompts = build_prompt(combined_data, selected_fields, None, active_outfit=character.active_outfit)
|
prompts = build_prompt(combined_data, selected_fields, None, active_outfit=character.active_outfit)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def register_routes(app):
|
|||||||
else:
|
else:
|
||||||
# Auto-include essential character fields (minimal set for batch/default generation)
|
# Auto-include essential character fields (minimal set for batch/default generation)
|
||||||
selected_fields = []
|
selected_fields = []
|
||||||
for key in ['base_specs', 'hair', 'eyes']:
|
for key in ['base', 'head']:
|
||||||
if character.data.get('identity', {}).get(key):
|
if character.data.get('identity', {}).get(key):
|
||||||
selected_fields.append(f'identity::{key}')
|
selected_fields.append(f'identity::{key}')
|
||||||
selected_fields.append('special::name')
|
selected_fields.append('special::name')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from services.prompts import build_prompt, build_extras_prompt
|
|||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize
|
||||||
from services.file_io import get_available_checkpoints
|
from services.file_io import get_available_checkpoints
|
||||||
|
from services.comfyui import get_loaded_checkpoint
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
@@ -25,6 +26,12 @@ def register_routes(app):
|
|||||||
if not checkpoints:
|
if not checkpoints:
|
||||||
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
|
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
|
||||||
|
|
||||||
|
# Default to whatever is currently loaded in ComfyUI, then settings default
|
||||||
|
selected_ckpt = get_loaded_checkpoint()
|
||||||
|
if not selected_ckpt:
|
||||||
|
default_path, _ = _get_default_checkpoint()
|
||||||
|
selected_ckpt = default_path
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
char_slug = request.form.get('character')
|
char_slug = request.form.get('character')
|
||||||
checkpoint = request.form.get('checkpoint')
|
checkpoint = request.form.get('checkpoint')
|
||||||
@@ -63,9 +70,17 @@ def register_routes(app):
|
|||||||
if extras:
|
if extras:
|
||||||
combined = f"{combined}, {extras}"
|
combined = f"{combined}, {extras}"
|
||||||
if custom_positive:
|
if custom_positive:
|
||||||
combined = f"{combined}, {custom_positive}"
|
combined = f"{custom_positive}, {combined}"
|
||||||
prompts["main"] = combined
|
prompts["main"] = combined
|
||||||
|
|
||||||
|
# Apply face/hand prompt overrides if provided
|
||||||
|
override_face = request.form.get('override_face_prompt', '').strip()
|
||||||
|
override_hand = request.form.get('override_hand_prompt', '').strip()
|
||||||
|
if override_face:
|
||||||
|
prompts["face"] = override_face
|
||||||
|
if override_hand:
|
||||||
|
prompts["hand"] = override_hand
|
||||||
|
|
||||||
# Parse optional seed
|
# Parse optional seed
|
||||||
seed_val = request.form.get('seed', '').strip()
|
seed_val = request.form.get('seed', '').strip()
|
||||||
fixed_seed = int(seed_val) if seed_val else None
|
fixed_seed = int(seed_val) if seed_val else None
|
||||||
@@ -103,7 +118,7 @@ def register_routes(app):
|
|||||||
|
|
||||||
return render_template('generator.html', characters=characters, checkpoints=checkpoints,
|
return render_template('generator.html', characters=characters, checkpoints=checkpoints,
|
||||||
actions=actions, outfits=outfits, scenes=scenes,
|
actions=actions, outfits=outfits, scenes=scenes,
|
||||||
styles=styles, detailers=detailers)
|
styles=styles, detailers=detailers, selected_ckpt=selected_ckpt)
|
||||||
|
|
||||||
@app.route('/generator/preview_prompt', methods=['POST'])
|
@app.route('/generator/preview_prompt', methods=['POST'])
|
||||||
def generator_preview_prompt():
|
def generator_preview_prompt():
|
||||||
@@ -134,6 +149,6 @@ def register_routes(app):
|
|||||||
if extras:
|
if extras:
|
||||||
combined = f"{combined}, {extras}"
|
combined = f"{combined}, {extras}"
|
||||||
if custom_positive:
|
if custom_positive:
|
||||||
combined = f"{combined}, {custom_positive}"
|
combined = f"{custom_positive}, {combined}"
|
||||||
|
|
||||||
return {'prompt': combined}
|
return {'prompt': combined, 'face': prompts['face'], 'hand': prompts['hand']}
|
||||||
|
|||||||
@@ -356,16 +356,13 @@ Character ID: {character_slug}"""
|
|||||||
"character_id": character_slug,
|
"character_id": character_slug,
|
||||||
"character_name": character_name,
|
"character_name": character_name,
|
||||||
"identity": {
|
"identity": {
|
||||||
"base_specs": lora_data.get('lora_triggers', ''),
|
"base": lora_data.get('lora_triggers', ''),
|
||||||
"hair": "",
|
"head": "",
|
||||||
"eyes": "",
|
"upper_body": "",
|
||||||
|
"lower_body": "",
|
||||||
"hands": "",
|
"hands": "",
|
||||||
"arms": "",
|
|
||||||
"torso": "",
|
|
||||||
"pelvis": "",
|
|
||||||
"legs": "",
|
|
||||||
"feet": "",
|
"feet": "",
|
||||||
"extra": ""
|
"additional": ""
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"expression": "",
|
"expression": "",
|
||||||
@@ -373,14 +370,13 @@ Character ID: {character_slug}"""
|
|||||||
"scene": ""
|
"scene": ""
|
||||||
},
|
},
|
||||||
"wardrobe": {
|
"wardrobe": {
|
||||||
"full_body": "",
|
"base": "",
|
||||||
"headwear": "",
|
"head": "",
|
||||||
"top": "",
|
"upper_body": "",
|
||||||
"bottom": "",
|
"lower_body": "",
|
||||||
"legwear": "",
|
|
||||||
"footwear": "",
|
|
||||||
"hands": "",
|
"hands": "",
|
||||||
"accessories": ""
|
"feet": "",
|
||||||
|
"additional": ""
|
||||||
},
|
},
|
||||||
"styles": {
|
"styles": {
|
||||||
"aesthetic": "",
|
"aesthetic": "",
|
||||||
|
|||||||
153
routes/multi_char.py
Normal file
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
|
# No explicit field selection (e.g. batch generation) — build a selection
|
||||||
# that includes identity + wardrobe + name + lora triggers, but NOT character
|
# that includes identity + wardrobe + name + lora triggers, but NOT character
|
||||||
# defaults (expression, pose, scene), so outfit covers stay generic.
|
# defaults (expression, pose, scene), so outfit covers stay generic.
|
||||||
for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']:
|
from utils import _IDENTITY_KEYS, _WARDROBE_KEYS
|
||||||
|
for key in _IDENTITY_KEYS:
|
||||||
if character.data.get('identity', {}).get(key):
|
if character.data.get('identity', {}).get(key):
|
||||||
selected_fields.append(f'identity::{key}')
|
selected_fields.append(f'identity::{key}')
|
||||||
outfit_wardrobe = outfit.data.get('wardrobe', {})
|
outfit_wardrobe = outfit.data.get('wardrobe', {})
|
||||||
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']:
|
for key in _WARDROBE_KEYS:
|
||||||
if outfit_wardrobe.get(key):
|
if outfit_wardrobe.get(key):
|
||||||
selected_fields.append(f'wardrobe::{key}')
|
selected_fields.append(f'wardrobe::{key}')
|
||||||
selected_fields.append('special::name')
|
selected_fields.append('special::name')
|
||||||
@@ -456,14 +457,13 @@ def register_routes(app):
|
|||||||
# Ensure required fields exist
|
# Ensure required fields exist
|
||||||
if 'wardrobe' not in outfit_data:
|
if 'wardrobe' not in outfit_data:
|
||||||
outfit_data['wardrobe'] = {
|
outfit_data['wardrobe'] = {
|
||||||
"full_body": "",
|
"base": "",
|
||||||
"headwear": "",
|
"head": "",
|
||||||
"top": "",
|
"upper_body": "",
|
||||||
"bottom": "",
|
"lower_body": "",
|
||||||
"legwear": "",
|
|
||||||
"footwear": "",
|
|
||||||
"hands": "",
|
"hands": "",
|
||||||
"accessories": ""
|
"feet": "",
|
||||||
|
"additional": ""
|
||||||
}
|
}
|
||||||
if 'lora' not in outfit_data:
|
if 'lora' not in outfit_data:
|
||||||
outfit_data['lora'] = {
|
outfit_data['lora'] = {
|
||||||
@@ -484,14 +484,13 @@ def register_routes(app):
|
|||||||
"outfit_id": safe_slug,
|
"outfit_id": safe_slug,
|
||||||
"outfit_name": name,
|
"outfit_name": name,
|
||||||
"wardrobe": {
|
"wardrobe": {
|
||||||
"full_body": "",
|
"base": "",
|
||||||
"headwear": "",
|
"head": "",
|
||||||
"top": "",
|
"upper_body": "",
|
||||||
"bottom": "",
|
"lower_body": "",
|
||||||
"legwear": "",
|
|
||||||
"footwear": "",
|
|
||||||
"hands": "",
|
"hands": "",
|
||||||
"accessories": ""
|
"feet": "",
|
||||||
|
"additional": ""
|
||||||
},
|
},
|
||||||
"lora": {
|
"lora": {
|
||||||
"lora_name": "",
|
"lora_name": "",
|
||||||
|
|||||||
@@ -70,18 +70,24 @@ def register_routes(app):
|
|||||||
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
|
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
|
||||||
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
|
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
|
||||||
|
|
||||||
# Checkpoint: preset override or session default
|
# Checkpoint: form override > preset config > session default
|
||||||
preset_ckpt = ckpt_cfg.get('checkpoint_path')
|
checkpoint_override = request.form.get('checkpoint_override', '').strip()
|
||||||
if preset_ckpt == 'random':
|
if checkpoint_override:
|
||||||
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first()
|
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint_override).first()
|
||||||
ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None
|
ckpt_path = checkpoint_override
|
||||||
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
|
||||||
elif preset_ckpt:
|
|
||||||
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first()
|
|
||||||
ckpt_path = preset_ckpt
|
|
||||||
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||||
else:
|
else:
|
||||||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
preset_ckpt = ckpt_cfg.get('checkpoint_path')
|
||||||
|
if preset_ckpt == 'random':
|
||||||
|
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first()
|
||||||
|
ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None
|
||||||
|
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||||
|
elif preset_ckpt:
|
||||||
|
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first()
|
||||||
|
ckpt_path = preset_ckpt
|
||||||
|
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||||
|
else:
|
||||||
|
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||||||
|
|
||||||
# Resolve selected fields from preset toggles
|
# Resolve selected fields from preset toggles
|
||||||
selected_fields = _resolve_preset_fields(data)
|
selected_fields = _resolve_preset_fields(data)
|
||||||
@@ -107,7 +113,8 @@ def register_routes(app):
|
|||||||
extras_parts = []
|
extras_parts = []
|
||||||
if action_obj:
|
if action_obj:
|
||||||
action_fields = action_cfg.get('fields', {})
|
action_fields = action_cfg.get('fields', {})
|
||||||
for key in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']:
|
from utils import _BODY_GROUP_KEYS
|
||||||
|
for key in _BODY_GROUP_KEYS:
|
||||||
val_cfg = action_fields.get(key, True)
|
val_cfg = action_fields.get(key, True)
|
||||||
if val_cfg == 'random':
|
if val_cfg == 'random':
|
||||||
val_cfg = random.choice([True, False])
|
val_cfg = random.choice([True, False])
|
||||||
@@ -172,6 +179,23 @@ def register_routes(app):
|
|||||||
seed_val = request.form.get('seed', '').strip()
|
seed_val = request.form.get('seed', '').strip()
|
||||||
fixed_seed = int(seed_val) if seed_val else None
|
fixed_seed = int(seed_val) if seed_val else None
|
||||||
|
|
||||||
|
# Resolution: form override > preset config > workflow default
|
||||||
|
res_cfg = data.get('resolution', {})
|
||||||
|
form_width = request.form.get('width', '').strip()
|
||||||
|
form_height = request.form.get('height', '').strip()
|
||||||
|
if form_width and form_height:
|
||||||
|
gen_width = int(form_width)
|
||||||
|
gen_height = int(form_height)
|
||||||
|
elif res_cfg.get('random', False):
|
||||||
|
_RES_OPTIONS = [
|
||||||
|
(1024, 1024), (1152, 896), (896, 1152), (1344, 768),
|
||||||
|
(768, 1344), (1280, 800), (800, 1280),
|
||||||
|
]
|
||||||
|
gen_width, gen_height = random.choice(_RES_OPTIONS)
|
||||||
|
else:
|
||||||
|
gen_width = res_cfg.get('width') or None
|
||||||
|
gen_height = res_cfg.get('height') or None
|
||||||
|
|
||||||
workflow = _prepare_workflow(
|
workflow = _prepare_workflow(
|
||||||
workflow, character, prompts,
|
workflow, character, prompts,
|
||||||
checkpoint=ckpt_path, checkpoint_data=ckpt_data,
|
checkpoint=ckpt_path, checkpoint_data=ckpt_data,
|
||||||
@@ -183,6 +207,8 @@ def register_routes(app):
|
|||||||
detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None,
|
detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None,
|
||||||
look=look_obj,
|
look=look_obj,
|
||||||
fixed_seed=fixed_seed,
|
fixed_seed=fixed_seed,
|
||||||
|
width=gen_width,
|
||||||
|
height=gen_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
label = f"Preset: {preset.name} – {action}"
|
label = f"Preset: {preset.name} – {action}"
|
||||||
@@ -258,13 +284,13 @@ def register_routes(app):
|
|||||||
'use_lora': request.form.get('char_use_lora') == 'on',
|
'use_lora': request.form.get('char_use_lora') == 'on',
|
||||||
'fields': {
|
'fields': {
|
||||||
'identity': {k: _tog(request.form.get(f'id_{k}', 'true'))
|
'identity': {k: _tog(request.form.get(f'id_{k}', 'true'))
|
||||||
for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']},
|
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
|
||||||
'defaults': {k: _tog(request.form.get(f'def_{k}', 'false'))
|
'defaults': {k: _tog(request.form.get(f'def_{k}', 'false'))
|
||||||
for k in ['expression', 'pose', 'scene']},
|
for k in ['expression', 'pose', 'scene']},
|
||||||
'wardrobe': {
|
'wardrobe': {
|
||||||
'outfit': request.form.get('wardrobe_outfit', 'default') or 'default',
|
'outfit': request.form.get('wardrobe_outfit', 'default') or 'default',
|
||||||
'fields': {k: _tog(request.form.get(f'wd_{k}', 'true'))
|
'fields': {k: _tog(request.form.get(f'wd_{k}', 'true'))
|
||||||
for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']},
|
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -273,7 +299,7 @@ def register_routes(app):
|
|||||||
'action': {'action_id': _entity_id(request.form.get('action_id')),
|
'action': {'action_id': _entity_id(request.form.get('action_id')),
|
||||||
'use_lora': request.form.get('action_use_lora') == 'on',
|
'use_lora': request.form.get('action_use_lora') == 'on',
|
||||||
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
|
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
|
||||||
for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}},
|
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
|
||||||
'style': {'style_id': _entity_id(request.form.get('style_id')),
|
'style': {'style_id': _entity_id(request.form.get('style_id')),
|
||||||
'use_lora': request.form.get('style_use_lora') == 'on'},
|
'use_lora': request.form.get('style_use_lora') == 'on'},
|
||||||
'scene': {'scene_id': _entity_id(request.form.get('scene_id')),
|
'scene': {'scene_id': _entity_id(request.form.get('scene_id')),
|
||||||
@@ -284,6 +310,11 @@ def register_routes(app):
|
|||||||
'use_lora': request.form.get('detailer_use_lora') == 'on'},
|
'use_lora': request.form.get('detailer_use_lora') == 'on'},
|
||||||
'look': {'look_id': _entity_id(request.form.get('look_id'))},
|
'look': {'look_id': _entity_id(request.form.get('look_id'))},
|
||||||
'checkpoint': {'checkpoint_path': _entity_id(request.form.get('checkpoint_path'))},
|
'checkpoint': {'checkpoint_path': _entity_id(request.form.get('checkpoint_path'))},
|
||||||
|
'resolution': {
|
||||||
|
'width': int(request.form.get('res_width', 1024)),
|
||||||
|
'height': int(request.form.get('res_height', 1024)),
|
||||||
|
'random': request.form.get('res_random') == 'on',
|
||||||
|
},
|
||||||
'tags': [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()],
|
'tags': [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,20 +430,21 @@ def register_routes(app):
|
|||||||
preset_data = {
|
preset_data = {
|
||||||
'character': {'character_id': 'random', 'use_lora': True,
|
'character': {'character_id': 'random', 'use_lora': True,
|
||||||
'fields': {
|
'fields': {
|
||||||
'identity': {k: True for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']},
|
'identity': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
|
||||||
'defaults': {k: False for k in ['expression', 'pose', 'scene']},
|
'defaults': {k: False for k in ['expression', 'pose', 'scene']},
|
||||||
'wardrobe': {'outfit': 'default',
|
'wardrobe': {'outfit': 'default',
|
||||||
'fields': {k: True for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}},
|
'fields': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
|
||||||
}},
|
}},
|
||||||
'outfit': {'outfit_id': None, 'use_lora': True},
|
'outfit': {'outfit_id': None, 'use_lora': True},
|
||||||
'action': {'action_id': None, 'use_lora': True,
|
'action': {'action_id': None, 'use_lora': True,
|
||||||
'fields': {k: True for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}},
|
'fields': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
|
||||||
'style': {'style_id': None, 'use_lora': True},
|
'style': {'style_id': None, 'use_lora': True},
|
||||||
'scene': {'scene_id': None, 'use_lora': True,
|
'scene': {'scene_id': None, 'use_lora': True,
|
||||||
'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}},
|
'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}},
|
||||||
'detailer': {'detailer_id': None, 'use_lora': True},
|
'detailer': {'detailer_id': None, 'use_lora': True},
|
||||||
'look': {'look_id': None},
|
'look': {'look_id': None},
|
||||||
'checkpoint': {'checkpoint_path': None},
|
'checkpoint': {'checkpoint_path': None},
|
||||||
|
'resolution': {'width': 1024, 'height': 1024, 'random': False},
|
||||||
'tags': [],
|
'tags': [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
routes/quick.py
Normal file
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:
|
else:
|
||||||
# Auto-include essential character fields (minimal set for batch/default generation)
|
# Auto-include essential character fields (minimal set for batch/default generation)
|
||||||
selected_fields = []
|
selected_fields = []
|
||||||
for key in ['base_specs', 'hair', 'eyes']:
|
for key in ['base', 'head']:
|
||||||
if character.data.get('identity', {}).get(key):
|
if character.data.get('identity', {}).get(key):
|
||||||
selected_fields.append(f'identity::{key}')
|
selected_fields.append(f'identity::{key}')
|
||||||
selected_fields.append('special::name')
|
selected_fields.append('special::name')
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ def register_routes(app):
|
|||||||
if character:
|
if character:
|
||||||
identity = character.data.get('identity', {})
|
identity = character.data.get('identity', {})
|
||||||
defaults = character.data.get('defaults', {})
|
defaults = character.data.get('defaults', {})
|
||||||
char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'),
|
char_parts = [v for v in [identity.get('base'), identity.get('head'),
|
||||||
identity.get('eyes'), defaults.get('expression')] if v]
|
|
||||||
face_parts = [v for v in [identity.get('hair'), identity.get('eyes'),
|
|
||||||
defaults.get('expression')] if v]
|
defaults.get('expression')] if v]
|
||||||
hand_parts = [v for v in [wardrobe.get('hands'), wardrobe.get('gloves')] if v]
|
face_parts = [v for v in [identity.get('head'),
|
||||||
|
defaults.get('expression')] if v]
|
||||||
|
hand_parts = [v for v in [wardrobe.get('hands')] if v]
|
||||||
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags
|
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags
|
||||||
return {
|
return {
|
||||||
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
||||||
@@ -94,17 +94,16 @@ def register_routes(app):
|
|||||||
action_data = entity.data.get('action', {})
|
action_data = entity.data.get('action', {})
|
||||||
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||||
tags = entity.data.get('tags', [])
|
tags = entity.data.get('tags', [])
|
||||||
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional']
|
from utils import _BODY_GROUP_KEYS
|
||||||
pose_parts = [action_data.get(k, '') for k in pose_fields if action_data.get(k)]
|
pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
|
||||||
expr_parts = [action_data.get(k, '') for k in ['head', 'eyes'] if action_data.get(k)]
|
expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
|
||||||
char_parts = []
|
char_parts = []
|
||||||
face_parts = list(expr_parts)
|
face_parts = list(expr_parts)
|
||||||
hand_parts = [action_data.get('hands', '')] if action_data.get('hands') else []
|
hand_parts = [action_data.get('hands', '')] if action_data.get('hands') else []
|
||||||
if character:
|
if character:
|
||||||
identity = character.data.get('identity', {})
|
identity = character.data.get('identity', {})
|
||||||
char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'),
|
char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
|
||||||
identity.get('eyes')] if v]
|
face_parts = [v for v in [identity.get('head')] + expr_parts if v]
|
||||||
face_parts = [v for v in [identity.get('hair'), identity.get('eyes')] + expr_parts if v]
|
|
||||||
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
|
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
|
||||||
return {
|
return {
|
||||||
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
||||||
@@ -130,15 +129,15 @@ def register_routes(app):
|
|||||||
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p]
|
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p]
|
||||||
|
|
||||||
char_data_no_lora = _get_character_data_without_lora(character)
|
char_data_no_lora = _get_character_data_without_lora(character)
|
||||||
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': ''}
|
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''}
|
||||||
entity_str = ', '.join(entity_parts)
|
entity_str = ', '.join(entity_parts)
|
||||||
if entity_str:
|
if entity_str:
|
||||||
base['main'] = f"{base['main']}, {entity_str}" if base['main'] else entity_str
|
base['main'] = f"{base['main']}, {entity_str}" if base['main'] else entity_str
|
||||||
|
|
||||||
if action is not None:
|
if action is not None:
|
||||||
action_data = action.data.get('action', {})
|
action_data = action.data.get('action', {})
|
||||||
action_parts = [action_data.get(k, '') for k in
|
from utils import _BODY_GROUP_KEYS
|
||||||
['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional', 'head', 'eyes']
|
action_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS
|
||||||
if action_data.get(k)]
|
if action_data.get(k)]
|
||||||
action_str = ', '.join(action_parts)
|
action_str = ', '.join(action_parts)
|
||||||
if action_str:
|
if action_str:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def register_routes(app):
|
|||||||
else:
|
else:
|
||||||
# Auto-include essential character fields (minimal set for batch/default generation)
|
# Auto-include essential character fields (minimal set for batch/default generation)
|
||||||
selected_fields = []
|
selected_fields = []
|
||||||
for key in ['base_specs', 'hair', 'eyes']:
|
for key in ['base', 'head']:
|
||||||
if character.data.get('identity', {}).get(key):
|
if character.data.get('identity', {}).get(key):
|
||||||
selected_fields.append(f'identity::{key}')
|
selected_fields.append(f'identity::{key}')
|
||||||
selected_fields.append('special::name')
|
selected_fields.append('special::name')
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ def register_routes(app):
|
|||||||
'outfit_id': slug,
|
'outfit_id': slug,
|
||||||
'outfit_name': name,
|
'outfit_name': name,
|
||||||
'wardrobe': source_data.get('wardrobe', {
|
'wardrobe': source_data.get('wardrobe', {
|
||||||
'full_body': '', 'headwear': '', 'top': '', 'bottom': '',
|
'base': '', 'head': '', 'upper_body': '', 'lower_body': '',
|
||||||
'legwear': '', 'footwear': '', 'hands': '', 'accessories': ''
|
'hands': '', 'feet': '', 'additional': ''
|
||||||
}),
|
}),
|
||||||
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 0.8, 'lora_triggers': ''}),
|
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 0.8, 'lora_triggers': ''}),
|
||||||
'tags': source_data.get('tags', []),
|
'tags': source_data.get('tags', []),
|
||||||
@@ -99,8 +99,8 @@ def register_routes(app):
|
|||||||
'action_id': slug,
|
'action_id': slug,
|
||||||
'action_name': name,
|
'action_name': name,
|
||||||
'action': source_data.get('action', {
|
'action': source_data.get('action', {
|
||||||
'full_body': '', 'head': '', 'eyes': '', 'arms': '', 'hands': '',
|
'base': '', 'head': '', 'upper_body': '', 'lower_body': '',
|
||||||
'torso': '', 'pelvis': '', 'legs': '', 'feet': '', 'additional': ''
|
'hands': '', 'feet': '', 'additional': ''
|
||||||
}),
|
}),
|
||||||
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}),
|
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}),
|
||||||
'tags': source_data.get('tags', []),
|
'tags': source_data.get('tags', []),
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ from flask import current_app
|
|||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
|
def get_loaded_checkpoint():
|
||||||
|
"""Return the checkpoint path currently loaded in ComfyUI, or None."""
|
||||||
|
try:
|
||||||
|
url = current_app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188')
|
||||||
|
resp = requests.get(f'{url}/history', timeout=3)
|
||||||
|
if resp.ok:
|
||||||
|
history = resp.json()
|
||||||
|
if history:
|
||||||
|
latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', ''))
|
||||||
|
nodes = latest.get('prompt', [None, None, {}])[2]
|
||||||
|
return nodes.get('4', {}).get('inputs', {}).get('ckpt_name')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _ensure_checkpoint_loaded(checkpoint_path):
|
def _ensure_checkpoint_loaded(checkpoint_path):
|
||||||
"""Check if the desired checkpoint is loaded in ComfyUI, and force reload if not."""
|
"""Check if the desired checkpoint is loaded in ComfyUI, and force reload if not."""
|
||||||
if not checkpoint_path:
|
if not checkpoint_path:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
from models import db, Character
|
from models import db, Character
|
||||||
from utils import _IDENTITY_KEYS, _WARDROBE_KEYS, parse_orientation
|
from utils import _IDENTITY_KEYS, _WARDROBE_KEYS, _BODY_GROUP_KEYS, parse_orientation
|
||||||
|
|
||||||
|
|
||||||
def _dedup_tags(prompt_str):
|
def _dedup_tags(prompt_str):
|
||||||
@@ -114,15 +114,21 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
|||||||
style_data = data.get('style', {})
|
style_data = data.get('style', {})
|
||||||
participants = data.get('participants', {})
|
participants = data.get('participants', {})
|
||||||
|
|
||||||
# Pre-calculate Hand/Glove priority
|
# Helper: collect selected values from identity + wardrobe for a given group key
|
||||||
# Priority: wardrobe gloves > wardrobe hands (outfit) > identity hands (character)
|
def _group_val(key):
|
||||||
hand_val = ""
|
parts = []
|
||||||
if wardrobe.get('gloves') and is_selected('wardrobe', 'gloves'):
|
id_val = identity.get(key, '')
|
||||||
hand_val = wardrobe.get('gloves')
|
wd_val = wardrobe.get(key, '')
|
||||||
elif wardrobe.get('hands') and is_selected('wardrobe', 'hands'):
|
if id_val and is_selected('identity', key):
|
||||||
hand_val = wardrobe.get('hands')
|
val = id_val
|
||||||
elif identity.get('hands') and is_selected('identity', 'hands'):
|
# Filter out conflicting tags from base if participants data is present
|
||||||
hand_val = identity.get('hands')
|
if participants and key == 'base':
|
||||||
|
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
|
||||||
|
if val:
|
||||||
|
parts.append(val)
|
||||||
|
if wd_val and is_selected('wardrobe', key):
|
||||||
|
parts.append(wd_val)
|
||||||
|
return ', '.join(parts)
|
||||||
|
|
||||||
# 1. Main Prompt
|
# 1. Main Prompt
|
||||||
parts = []
|
parts = []
|
||||||
@@ -131,12 +137,10 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
|||||||
if participants:
|
if participants:
|
||||||
if participants.get('solo_focus') == 'true':
|
if participants.get('solo_focus') == 'true':
|
||||||
parts.append('(solo focus:1.2)')
|
parts.append('(solo focus:1.2)')
|
||||||
|
|
||||||
orientation = participants.get('orientation', '')
|
orientation = participants.get('orientation', '')
|
||||||
if orientation:
|
if orientation:
|
||||||
parts.extend(parse_orientation(orientation))
|
parts.extend(parse_orientation(orientation))
|
||||||
else:
|
else:
|
||||||
# Default behavior
|
|
||||||
parts.append("(solo:1.2)")
|
parts.append("(solo:1.2)")
|
||||||
|
|
||||||
# Use character_id (underscores to spaces) for tags compatibility
|
# Use character_id (underscores to spaces) for tags compatibility
|
||||||
@@ -144,13 +148,10 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
|||||||
if char_tag and is_selected('special', 'name'):
|
if char_tag and is_selected('special', 'name'):
|
||||||
parts.append(char_tag)
|
parts.append(char_tag)
|
||||||
|
|
||||||
for key in ['base_specs', 'hair', 'eyes', 'extra']:
|
# Add all body groups to main prompt
|
||||||
val = identity.get(key)
|
for key in _BODY_GROUP_KEYS:
|
||||||
if val and is_selected('identity', key):
|
val = _group_val(key)
|
||||||
# Filter out conflicting tags if participants data is present
|
if val:
|
||||||
if participants and key == 'base_specs':
|
|
||||||
# Remove 1girl, 1boy, solo, etc.
|
|
||||||
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
|
|
||||||
parts.append(val)
|
parts.append(val)
|
||||||
|
|
||||||
# Add defaults (expression, pose, scene)
|
# Add defaults (expression, pose, scene)
|
||||||
@@ -159,21 +160,12 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
|||||||
if val and is_selected('defaults', key):
|
if val and is_selected('defaults', key):
|
||||||
parts.append(val)
|
parts.append(val)
|
||||||
|
|
||||||
# Add hand priority value to main prompt
|
|
||||||
if hand_val:
|
|
||||||
parts.append(hand_val)
|
|
||||||
|
|
||||||
for key in ['full_body', 'top', 'bottom', 'headwear', 'legwear', 'footwear', 'accessories']:
|
|
||||||
val = wardrobe.get(key)
|
|
||||||
if val and is_selected('wardrobe', key):
|
|
||||||
parts.append(val)
|
|
||||||
|
|
||||||
# Standard character styles
|
# Standard character styles
|
||||||
char_aesthetic = data.get('styles', {}).get('aesthetic')
|
char_aesthetic = data.get('styles', {}).get('aesthetic')
|
||||||
if char_aesthetic and is_selected('styles', 'aesthetic'):
|
if char_aesthetic and is_selected('styles', 'aesthetic'):
|
||||||
parts.append(f"{char_aesthetic} style")
|
parts.append(f"{char_aesthetic} style")
|
||||||
|
|
||||||
# New Styles Gallery logic
|
# Styles Gallery logic
|
||||||
if style_data.get('artist_name') and is_selected('style', 'artist_name'):
|
if style_data.get('artist_name') and is_selected('style', 'artist_name'):
|
||||||
parts.append(f"by {style_data['artist_name']}")
|
parts.append(f"by {style_data['artist_name']}")
|
||||||
if style_data.get('artistic_style') and is_selected('style', 'artistic_style'):
|
if style_data.get('artistic_style') and is_selected('style', 'artistic_style'):
|
||||||
@@ -187,26 +179,98 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
|||||||
if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'):
|
if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'):
|
||||||
parts.append(lora.get('lora_triggers'))
|
parts.append(lora.get('lora_triggers'))
|
||||||
|
|
||||||
# 2. Face Prompt: Tag, Eyes, Expression, Headwear, Action details
|
# 2. Face Prompt: head group + expression + action head
|
||||||
face_parts = []
|
face_parts = []
|
||||||
if char_tag and is_selected('special', 'name'): face_parts.append(char_tag)
|
if char_tag and is_selected('special', 'name'):
|
||||||
if identity.get('eyes') and is_selected('identity', 'eyes'): face_parts.append(identity.get('eyes'))
|
face_parts.append(char_tag)
|
||||||
if defaults.get('expression') and is_selected('defaults', 'expression'): face_parts.append(defaults.get('expression'))
|
head_val = _group_val('head')
|
||||||
if wardrobe.get('headwear') and is_selected('wardrobe', 'headwear'): face_parts.append(wardrobe.get('headwear'))
|
if head_val:
|
||||||
|
face_parts.append(head_val)
|
||||||
|
if defaults.get('expression') and is_selected('defaults', 'expression'):
|
||||||
|
face_parts.append(defaults.get('expression'))
|
||||||
|
if action_data.get('head') and is_selected('action', 'head'):
|
||||||
|
face_parts.append(action_data.get('head'))
|
||||||
|
|
||||||
# Add specific Action expression details if available
|
# 3. Hand Prompt: hands group + action hands
|
||||||
if action_data.get('head') and is_selected('action', 'head'): face_parts.append(action_data.get('head'))
|
hand_parts = []
|
||||||
if action_data.get('eyes') and is_selected('action', 'eyes'): face_parts.append(action_data.get('eyes'))
|
hands_val = _group_val('hands')
|
||||||
|
if hands_val:
|
||||||
|
hand_parts.append(hands_val)
|
||||||
|
if action_data.get('hands') and is_selected('action', 'hands'):
|
||||||
|
hand_parts.append(action_data.get('hands'))
|
||||||
|
|
||||||
# 3. Hand Prompt: Hand value (Gloves or Hands), Action details
|
# 4. Feet Prompt: feet group + action feet
|
||||||
hand_parts = [hand_val] if hand_val else []
|
feet_parts = []
|
||||||
if action_data.get('arms') and is_selected('action', 'arms'): hand_parts.append(action_data.get('arms'))
|
feet_val = _group_val('feet')
|
||||||
if action_data.get('hands') and is_selected('action', 'hands'): hand_parts.append(action_data.get('hands'))
|
if feet_val:
|
||||||
|
feet_parts.append(feet_val)
|
||||||
|
if action_data.get('feet') and is_selected('action', 'feet'):
|
||||||
|
feet_parts.append(action_data.get('feet'))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"main": _dedup_tags(", ".join(parts)),
|
"main": _dedup_tags(", ".join(parts)),
|
||||||
"face": _dedup_tags(", ".join(face_parts)),
|
"face": _dedup_tags(", ".join(face_parts)),
|
||||||
"hand": _dedup_tags(", ".join(hand_parts))
|
"hand": _dedup_tags(", ".join(hand_parts)),
|
||||||
|
"feet": _dedup_tags(", ".join(feet_parts)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_multi_prompt(char_a, char_b, extras_prompt=''):
|
||||||
|
"""Build prompts for a two-character generation using BREAK separation.
|
||||||
|
|
||||||
|
Returns dict with combined prompts (main, face, hand) and per-character
|
||||||
|
prompts (char_a_main, char_a_face, char_b_main, char_b_face) for the
|
||||||
|
per-person/face ADetailer passes.
|
||||||
|
"""
|
||||||
|
# Build individual prompts with all fields selected
|
||||||
|
prompts_a = build_prompt(char_a.data)
|
||||||
|
prompts_b = build_prompt(char_b.data)
|
||||||
|
|
||||||
|
# Strip solo/orientation tags from individual prompts — we'll add combined ones
|
||||||
|
_solo_orientation_tags = {
|
||||||
|
'solo', '(solo:1.2)', '(solo focus:1.2)',
|
||||||
|
'1girl', '1boy', '2girls', '2boys', '3girls', '3boys',
|
||||||
|
'hetero', 'yuri', 'yaoi',
|
||||||
|
'multiple girls', 'multiple boys',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _strip_tags(prompt_str, tags_to_remove):
|
||||||
|
parts = [t.strip() for t in prompt_str.split(',') if t.strip()]
|
||||||
|
return ', '.join(p for p in parts if p.lower() not in tags_to_remove)
|
||||||
|
|
||||||
|
main_a = _strip_tags(prompts_a['main'], _solo_orientation_tags)
|
||||||
|
main_b = _strip_tags(prompts_b['main'], _solo_orientation_tags)
|
||||||
|
|
||||||
|
# Compute combined orientation
|
||||||
|
orient_a = char_a.data.get('participants', {}).get('orientation', '1F')
|
||||||
|
orient_b = char_b.data.get('participants', {}).get('orientation', '1F')
|
||||||
|
|
||||||
|
# Count total M and F across both characters
|
||||||
|
combined_m = orient_a.upper().count('M') + orient_b.upper().count('M')
|
||||||
|
combined_f = orient_a.upper().count('F') + orient_b.upper().count('F')
|
||||||
|
combined_orientation = f"{combined_m}M{combined_f}F" if combined_m else f"{combined_f}F"
|
||||||
|
if combined_f == 0:
|
||||||
|
combined_orientation = f"{combined_m}M"
|
||||||
|
orientation_tags = parse_orientation(combined_orientation)
|
||||||
|
|
||||||
|
# Build combined main prompt with BREAK separation
|
||||||
|
orientation_str = ', '.join(orientation_tags)
|
||||||
|
combined_main = f"{orientation_str}, {main_a} BREAK {main_b}"
|
||||||
|
if extras_prompt:
|
||||||
|
combined_main = f"{extras_prompt}, {combined_main}"
|
||||||
|
|
||||||
|
# Merge face/hand prompts for the hand detailer (shared, not per-character)
|
||||||
|
hand_parts = [p for p in [prompts_a['hand'], prompts_b['hand']] if p]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"main": _dedup_tags(combined_main),
|
||||||
|
"face": "", # not used — per-character face prompts go to SEGS detailers
|
||||||
|
"hand": _dedup_tags(', '.join(hand_parts)),
|
||||||
|
# Per-character prompts for SEGS-based ADetailer passes
|
||||||
|
"char_a_main": _dedup_tags(main_a),
|
||||||
|
"char_a_face": _dedup_tags(prompts_a['face']),
|
||||||
|
"char_b_main": _dedup_tags(main_b),
|
||||||
|
"char_b_face": _dedup_tags(prompts_b['face']),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -220,7 +284,7 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
|
|||||||
if lora.get('lora_triggers'):
|
if lora.get('lora_triggers'):
|
||||||
parts.append(lora['lora_triggers'])
|
parts.append(lora['lora_triggers'])
|
||||||
parts.extend(data.get('tags', []))
|
parts.extend(data.get('tags', []))
|
||||||
for key in ['full_body', 'additional']:
|
for key in _BODY_GROUP_KEYS:
|
||||||
val = data.get('action', {}).get(key)
|
val = data.get('action', {}).get(key)
|
||||||
if val:
|
if val:
|
||||||
parts.append(val)
|
parts.append(val)
|
||||||
@@ -228,7 +292,7 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
|
|||||||
for outfit in outfits:
|
for outfit in outfits:
|
||||||
data = outfit.data
|
data = outfit.data
|
||||||
wardrobe = data.get('wardrobe', {})
|
wardrobe = data.get('wardrobe', {})
|
||||||
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'accessories']:
|
for key in _BODY_GROUP_KEYS:
|
||||||
val = wardrobe.get(key)
|
val = wardrobe.get(key)
|
||||||
if val:
|
if val:
|
||||||
parts.append(val)
|
parts.append(val)
|
||||||
|
|||||||
@@ -153,14 +153,13 @@ def ensure_default_outfit():
|
|||||||
"outfit_id": "default",
|
"outfit_id": "default",
|
||||||
"outfit_name": "Default",
|
"outfit_name": "Default",
|
||||||
"wardrobe": {
|
"wardrobe": {
|
||||||
"full_body": "",
|
"base": "",
|
||||||
"headwear": "",
|
"head": "",
|
||||||
"top": "",
|
"upper_body": "",
|
||||||
"bottom": "",
|
"lower_body": "",
|
||||||
"legwear": "",
|
|
||||||
"footwear": "",
|
|
||||||
"hands": "",
|
"hands": "",
|
||||||
"accessories": ""
|
"feet": "",
|
||||||
|
"additional": ""
|
||||||
},
|
},
|
||||||
"lora": {
|
"lora": {
|
||||||
"lora_name": "",
|
"lora_name": "",
|
||||||
@@ -360,7 +359,8 @@ def _resolve_preset_fields(preset_data):
|
|||||||
char_cfg = preset_data.get('character', {})
|
char_cfg = preset_data.get('character', {})
|
||||||
fields = char_cfg.get('fields', {})
|
fields = char_cfg.get('fields', {})
|
||||||
|
|
||||||
for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']:
|
from utils import _BODY_GROUP_KEYS
|
||||||
|
for key in _BODY_GROUP_KEYS:
|
||||||
val = fields.get('identity', {}).get(key, True)
|
val = fields.get('identity', {}).get(key, True)
|
||||||
if val == 'random':
|
if val == 'random':
|
||||||
val = random.choice([True, False])
|
val = random.choice([True, False])
|
||||||
@@ -375,7 +375,7 @@ def _resolve_preset_fields(preset_data):
|
|||||||
selected.append(f'defaults::{key}')
|
selected.append(f'defaults::{key}')
|
||||||
|
|
||||||
wardrobe_cfg = fields.get('wardrobe', {})
|
wardrobe_cfg = fields.get('wardrobe', {})
|
||||||
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']:
|
for key in _BODY_GROUP_KEYS:
|
||||||
val = wardrobe_cfg.get('fields', {}).get(key, True)
|
val = wardrobe_cfg.get('fields', {}).get(key, True)
|
||||||
if val == 'random':
|
if val == 'random':
|
||||||
val = random.choice([True, False])
|
val = random.choice([True, False])
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ from services.prompts import _cross_dedup_prompts
|
|||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
# Node IDs used by DetailerForEach in multi-char mode
|
||||||
|
_SEGS_DETAILER_NODES = ['46', '47', '53', '54']
|
||||||
|
# Node IDs for per-character CLIP prompts in multi-char mode
|
||||||
|
_SEGS_PROMPT_NODES = ['44', '45', '51', '52']
|
||||||
|
|
||||||
|
|
||||||
def _log_workflow_prompts(label, workflow):
|
def _log_workflow_prompts(label, workflow):
|
||||||
"""Log the final assembled ComfyUI prompts in a consistent, readable block."""
|
"""Log the final assembled ComfyUI prompts in a consistent, readable block."""
|
||||||
@@ -17,7 +22,7 @@ def _log_workflow_prompts(label, workflow):
|
|||||||
lora_details = []
|
lora_details = []
|
||||||
|
|
||||||
# Collect detailed LoRA information
|
# Collect detailed LoRA information
|
||||||
for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]:
|
for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene"), ("20", "char_b")]:
|
||||||
if node_id in workflow:
|
if node_id in workflow:
|
||||||
name = workflow[node_id]["inputs"].get("lora_name", "")
|
name = workflow[node_id]["inputs"].get("lora_name", "")
|
||||||
if name:
|
if name:
|
||||||
@@ -41,11 +46,18 @@ def _log_workflow_prompts(label, workflow):
|
|||||||
|
|
||||||
# Extract adetailer information
|
# Extract adetailer information
|
||||||
adetailer_info = []
|
adetailer_info = []
|
||||||
|
# Single-char mode: FaceDetailer nodes 11 + 13
|
||||||
for node_id, node_name in [("11", "Face"), ("13", "Hand")]:
|
for node_id, node_name in [("11", "Face"), ("13", "Hand")]:
|
||||||
if node_id in workflow:
|
if node_id in workflow:
|
||||||
adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, "
|
adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, "
|
||||||
f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, "
|
f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, "
|
||||||
f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}")
|
f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}")
|
||||||
|
# Multi-char mode: SEGS DetailerForEach nodes
|
||||||
|
for node_id, node_name in [("46", "Person A"), ("47", "Person B"), ("53", "Face A"), ("54", "Face B")]:
|
||||||
|
if node_id in workflow:
|
||||||
|
adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, "
|
||||||
|
f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, "
|
||||||
|
f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}")
|
||||||
|
|
||||||
face_text = workflow.get('14', {}).get('inputs', {}).get('text', '')
|
face_text = workflow.get('14', {}).get('inputs', {}).get('text', '')
|
||||||
hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '')
|
hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '')
|
||||||
@@ -95,6 +107,12 @@ def _log_workflow_prompts(label, workflow):
|
|||||||
if hand_text:
|
if hand_text:
|
||||||
lines.append(f" [H] Hand : {hand_text}")
|
lines.append(f" [H] Hand : {hand_text}")
|
||||||
|
|
||||||
|
# Multi-char per-character prompts
|
||||||
|
for node_id, lbl in [("44", "Person A"), ("45", "Person B"), ("51", "Face A"), ("52", "Face B")]:
|
||||||
|
txt = workflow.get(node_id, {}).get('inputs', {}).get('text', '')
|
||||||
|
if txt:
|
||||||
|
lines.append(f" [{lbl}] : {txt}")
|
||||||
|
|
||||||
lines.append(sep)
|
lines.append(sep)
|
||||||
logger.info("\n%s", "\n".join(lines))
|
logger.info("\n%s", "\n".join(lines))
|
||||||
|
|
||||||
@@ -119,8 +137,8 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
|
|||||||
if scheduler and '3' in workflow:
|
if scheduler and '3' in workflow:
|
||||||
workflow['3']['inputs']['scheduler'] = scheduler
|
workflow['3']['inputs']['scheduler'] = scheduler
|
||||||
|
|
||||||
# Face/hand detailers (nodes 11, 13)
|
# Face/hand detailers (nodes 11, 13) + multi-char SEGS detailers
|
||||||
for node_id in ['11', '13']:
|
for node_id in ['11', '13'] + _SEGS_DETAILER_NODES:
|
||||||
if node_id in workflow:
|
if node_id in workflow:
|
||||||
if steps:
|
if steps:
|
||||||
workflow[node_id]['inputs']['steps'] = int(steps)
|
workflow[node_id]['inputs']['steps'] = int(steps)
|
||||||
@@ -131,9 +149,9 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
|
|||||||
if scheduler:
|
if scheduler:
|
||||||
workflow[node_id]['inputs']['scheduler'] = scheduler
|
workflow[node_id]['inputs']['scheduler'] = scheduler
|
||||||
|
|
||||||
# Prepend base_positive to positive prompts (main + face/hand detailers)
|
# Prepend base_positive to all positive prompt nodes
|
||||||
if base_positive:
|
if base_positive:
|
||||||
for node_id in ['6', '14', '15']:
|
for node_id in ['6', '14', '15'] + _SEGS_PROMPT_NODES:
|
||||||
if node_id in workflow:
|
if node_id in workflow:
|
||||||
workflow[node_id]['inputs']['text'] = f"{base_positive}, {workflow[node_id]['inputs']['text']}"
|
workflow[node_id]['inputs']['text'] = f"{base_positive}, {workflow[node_id]['inputs']['text']}"
|
||||||
|
|
||||||
@@ -149,7 +167,7 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
|
|||||||
}
|
}
|
||||||
if '8' in workflow:
|
if '8' in workflow:
|
||||||
workflow['8']['inputs']['vae'] = ['21', 0]
|
workflow['8']['inputs']['vae'] = ['21', 0]
|
||||||
for node_id in ['11', '13']:
|
for node_id in ['11', '13'] + _SEGS_DETAILER_NODES:
|
||||||
if node_id in workflow:
|
if node_id in workflow:
|
||||||
workflow[node_id]['inputs']['vae'] = ['21', 0]
|
workflow[node_id]['inputs']['vae'] = ['21', 0]
|
||||||
|
|
||||||
@@ -187,12 +205,246 @@ def _get_default_checkpoint():
|
|||||||
return ckpt.checkpoint_path, ckpt.data or {}
|
return ckpt.checkpoint_path, ckpt.data or {}
|
||||||
|
|
||||||
|
|
||||||
def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None, action=None, style=None, detailer=None, scene=None, width=None, height=None, checkpoint_data=None, look=None, fixed_seed=None):
|
def _inject_multi_char_detailers(workflow, prompts, model_source, clip_source):
|
||||||
|
"""Replace single FaceDetailer (node 11) with per-character SEGS-based detailers.
|
||||||
|
|
||||||
|
Injects person detection + face detection pipelines that order detections
|
||||||
|
left-to-right and apply character A's prompt to the left person/face and
|
||||||
|
character B's prompt to the right person/face.
|
||||||
|
|
||||||
|
Nodes added:
|
||||||
|
40 - Person detector (UltralyticsDetectorProvider)
|
||||||
|
41 - Person SEGS detection (BboxDetectorSEGS)
|
||||||
|
42 - Filter: left person (char A)
|
||||||
|
43 - Filter: right person (char B)
|
||||||
|
44 - CLIPTextEncode: char A body prompt
|
||||||
|
45 - CLIPTextEncode: char B body prompt
|
||||||
|
46 - DetailerForEach: person A
|
||||||
|
47 - DetailerForEach: person B
|
||||||
|
48 - Face SEGS detection (BboxDetectorSEGS, reuses face detector node 10)
|
||||||
|
49 - Filter: left face (char A)
|
||||||
|
50 - Filter: right face (char B)
|
||||||
|
51 - CLIPTextEncode: char A face prompt
|
||||||
|
52 - CLIPTextEncode: char B face prompt
|
||||||
|
53 - DetailerForEach: face A
|
||||||
|
54 - DetailerForEach: face B
|
||||||
|
|
||||||
|
Image flow: VAEDecode(8) → PersonA(46) → PersonB(47) → FaceA(53) → FaceB(54) → Hand(13)
|
||||||
|
"""
|
||||||
|
vae_source = ["4", 2]
|
||||||
|
|
||||||
|
# Remove old single face detailer and its prompt — we replace them
|
||||||
|
workflow.pop('11', None)
|
||||||
|
workflow.pop('14', None)
|
||||||
|
|
||||||
|
# --- Person detection ---
|
||||||
|
workflow['40'] = {
|
||||||
|
'inputs': {'model_name': 'segm/person_yolov8m-seg.pt'},
|
||||||
|
'class_type': 'UltralyticsDetectorProvider'
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow['41'] = {
|
||||||
|
'inputs': {
|
||||||
|
'bbox_detector': ['40', 0],
|
||||||
|
'image': ['8', 0],
|
||||||
|
'threshold': 0.5,
|
||||||
|
'dilation': 10,
|
||||||
|
'crop_factor': 3.0,
|
||||||
|
'drop_size': 10,
|
||||||
|
'labels': 'all',
|
||||||
|
},
|
||||||
|
'class_type': 'BboxDetectorSEGS'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Order by x1 ascending (left to right), pick index 0 = leftmost person
|
||||||
|
workflow['42'] = {
|
||||||
|
'inputs': {
|
||||||
|
'segs': ['41', 0],
|
||||||
|
'target': 'x1',
|
||||||
|
'order': False,
|
||||||
|
'take_start': 0,
|
||||||
|
'take_count': 1,
|
||||||
|
},
|
||||||
|
'class_type': 'ImpactSEGSOrderedFilter'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pick index 1 = rightmost person
|
||||||
|
workflow['43'] = {
|
||||||
|
'inputs': {
|
||||||
|
'segs': ['41', 0],
|
||||||
|
'target': 'x1',
|
||||||
|
'order': False,
|
||||||
|
'take_start': 1,
|
||||||
|
'take_count': 1,
|
||||||
|
},
|
||||||
|
'class_type': 'ImpactSEGSOrderedFilter'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Per-character body prompts ---
|
||||||
|
workflow['44'] = {
|
||||||
|
'inputs': {'text': prompts.get('char_a_main', ''), 'clip': clip_source},
|
||||||
|
'class_type': 'CLIPTextEncode'
|
||||||
|
}
|
||||||
|
workflow['45'] = {
|
||||||
|
'inputs': {'text': prompts.get('char_b_main', ''), 'clip': clip_source},
|
||||||
|
'class_type': 'CLIPTextEncode'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Person detailing (DetailerForEach) ---
|
||||||
|
_person_base = {
|
||||||
|
'guide_size': 512,
|
||||||
|
'guide_size_for': True,
|
||||||
|
'max_size': 1024,
|
||||||
|
'seed': 0, # overwritten by seed step
|
||||||
|
'steps': 20, # overwritten by checkpoint settings
|
||||||
|
'cfg': 3.5, # overwritten by checkpoint settings
|
||||||
|
'sampler_name': 'euler_ancestral',
|
||||||
|
'scheduler': 'normal',
|
||||||
|
'denoise': 0.4,
|
||||||
|
'feather': 5,
|
||||||
|
'noise_mask': True,
|
||||||
|
'force_inpaint': True,
|
||||||
|
'wildcard': '',
|
||||||
|
'cycle': 1,
|
||||||
|
'inpaint_model': False,
|
||||||
|
'noise_mask_feather': 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow['46'] = {
|
||||||
|
'inputs': {
|
||||||
|
**_person_base,
|
||||||
|
'image': ['8', 0],
|
||||||
|
'segs': ['42', 0],
|
||||||
|
'model': model_source,
|
||||||
|
'clip': clip_source,
|
||||||
|
'vae': vae_source,
|
||||||
|
'positive': ['44', 0],
|
||||||
|
'negative': ['7', 0],
|
||||||
|
},
|
||||||
|
'class_type': 'DetailerForEach'
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow['47'] = {
|
||||||
|
'inputs': {
|
||||||
|
**_person_base,
|
||||||
|
'image': ['46', 0], # chains from person A output
|
||||||
|
'segs': ['43', 0],
|
||||||
|
'model': model_source,
|
||||||
|
'clip': clip_source,
|
||||||
|
'vae': vae_source,
|
||||||
|
'positive': ['45', 0],
|
||||||
|
'negative': ['7', 0],
|
||||||
|
},
|
||||||
|
'class_type': 'DetailerForEach'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Face detection (on person-detailed image) ---
|
||||||
|
workflow['48'] = {
|
||||||
|
'inputs': {
|
||||||
|
'bbox_detector': ['10', 0], # reuse existing face YOLO detector
|
||||||
|
'image': ['47', 0],
|
||||||
|
'threshold': 0.5,
|
||||||
|
'dilation': 10,
|
||||||
|
'crop_factor': 3.0,
|
||||||
|
'drop_size': 10,
|
||||||
|
'labels': 'all',
|
||||||
|
},
|
||||||
|
'class_type': 'BboxDetectorSEGS'
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow['49'] = {
|
||||||
|
'inputs': {
|
||||||
|
'segs': ['48', 0],
|
||||||
|
'target': 'x1',
|
||||||
|
'order': False,
|
||||||
|
'take_start': 0,
|
||||||
|
'take_count': 1,
|
||||||
|
},
|
||||||
|
'class_type': 'ImpactSEGSOrderedFilter'
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow['50'] = {
|
||||||
|
'inputs': {
|
||||||
|
'segs': ['48', 0],
|
||||||
|
'target': 'x1',
|
||||||
|
'order': False,
|
||||||
|
'take_start': 1,
|
||||||
|
'take_count': 1,
|
||||||
|
},
|
||||||
|
'class_type': 'ImpactSEGSOrderedFilter'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Per-character face prompts ---
|
||||||
|
workflow['51'] = {
|
||||||
|
'inputs': {'text': prompts.get('char_a_face', ''), 'clip': clip_source},
|
||||||
|
'class_type': 'CLIPTextEncode'
|
||||||
|
}
|
||||||
|
workflow['52'] = {
|
||||||
|
'inputs': {'text': prompts.get('char_b_face', ''), 'clip': clip_source},
|
||||||
|
'class_type': 'CLIPTextEncode'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Face detailing (DetailerForEach) ---
|
||||||
|
_face_base = {
|
||||||
|
'guide_size': 384,
|
||||||
|
'guide_size_for': True,
|
||||||
|
'max_size': 1024,
|
||||||
|
'seed': 0,
|
||||||
|
'steps': 20,
|
||||||
|
'cfg': 3.5,
|
||||||
|
'sampler_name': 'euler_ancestral',
|
||||||
|
'scheduler': 'normal',
|
||||||
|
'denoise': 0.5,
|
||||||
|
'feather': 5,
|
||||||
|
'noise_mask': True,
|
||||||
|
'force_inpaint': True,
|
||||||
|
'wildcard': '',
|
||||||
|
'cycle': 1,
|
||||||
|
'inpaint_model': False,
|
||||||
|
'noise_mask_feather': 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow['53'] = {
|
||||||
|
'inputs': {
|
||||||
|
**_face_base,
|
||||||
|
'image': ['47', 0],
|
||||||
|
'segs': ['49', 0],
|
||||||
|
'model': model_source,
|
||||||
|
'clip': clip_source,
|
||||||
|
'vae': vae_source,
|
||||||
|
'positive': ['51', 0],
|
||||||
|
'negative': ['7', 0],
|
||||||
|
},
|
||||||
|
'class_type': 'DetailerForEach'
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow['54'] = {
|
||||||
|
'inputs': {
|
||||||
|
**_face_base,
|
||||||
|
'image': ['53', 0], # chains from face A output
|
||||||
|
'segs': ['50', 0],
|
||||||
|
'model': model_source,
|
||||||
|
'clip': clip_source,
|
||||||
|
'vae': vae_source,
|
||||||
|
'positive': ['52', 0],
|
||||||
|
'negative': ['7', 0],
|
||||||
|
},
|
||||||
|
'class_type': 'DetailerForEach'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rewire hand detailer: image input from last face detailer instead of old node 11
|
||||||
|
if '13' in workflow:
|
||||||
|
workflow['13']['inputs']['image'] = ['54', 0]
|
||||||
|
|
||||||
|
logger.debug("Injected multi-char SEGS detailers (nodes 40-54)")
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None, action=None, style=None, detailer=None, scene=None, width=None, height=None, checkpoint_data=None, look=None, fixed_seed=None, character_b=None):
|
||||||
# 1. Update prompts using replacement to preserve embeddings
|
# 1. Update prompts using replacement to preserve embeddings
|
||||||
workflow["6"]["inputs"]["text"] = workflow["6"]["inputs"]["text"].replace("{{POSITIVE_PROMPT}}", prompts["main"])
|
workflow["6"]["inputs"]["text"] = workflow["6"]["inputs"]["text"].replace("{{POSITIVE_PROMPT}}", prompts["main"])
|
||||||
|
|
||||||
if custom_negative:
|
if custom_negative:
|
||||||
workflow["7"]["inputs"]["text"] = f"{workflow['7']['inputs']['text']}, {custom_negative}"
|
workflow["7"]["inputs"]["text"] = f"{custom_negative}, {workflow['7']['inputs']['text']}"
|
||||||
|
|
||||||
if "14" in workflow:
|
if "14" in workflow:
|
||||||
workflow["14"]["inputs"]["text"] = workflow["14"]["inputs"]["text"].replace("{{FACE_PROMPT}}", prompts["face"])
|
workflow["14"]["inputs"]["text"] = workflow["14"]["inputs"]["text"].replace("{{FACE_PROMPT}}", prompts["face"])
|
||||||
@@ -289,23 +541,39 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
|
|||||||
clip_source = ["19", 1]
|
clip_source = ["19", 1]
|
||||||
logger.debug("Style/Detailer LoRA: %s @ %s", style_lora_name, _w19)
|
logger.debug("Style/Detailer LoRA: %s @ %s", style_lora_name, _w19)
|
||||||
|
|
||||||
# Apply connections to all model/clip consumers
|
# Second character LoRA (Node 20) - for multi-character generation
|
||||||
workflow["3"]["inputs"]["model"] = model_source
|
if character_b:
|
||||||
workflow["11"]["inputs"]["model"] = model_source
|
char_b_lora_data = character_b.data.get('lora', {})
|
||||||
workflow["13"]["inputs"]["model"] = model_source
|
char_b_lora_name = char_b_lora_data.get('lora_name')
|
||||||
|
if char_b_lora_name and "20" in workflow:
|
||||||
|
_w20 = _resolve_lora_weight(char_b_lora_data)
|
||||||
|
workflow["20"]["inputs"]["lora_name"] = char_b_lora_name
|
||||||
|
workflow["20"]["inputs"]["strength_model"] = _w20
|
||||||
|
workflow["20"]["inputs"]["strength_clip"] = _w20
|
||||||
|
workflow["20"]["inputs"]["model"] = model_source
|
||||||
|
workflow["20"]["inputs"]["clip"] = clip_source
|
||||||
|
model_source = ["20", 0]
|
||||||
|
clip_source = ["20", 1]
|
||||||
|
logger.debug("Character B LoRA: %s @ %s", char_b_lora_name, _w20)
|
||||||
|
|
||||||
workflow["6"]["inputs"]["clip"] = clip_source
|
# 3b. Multi-char: inject per-character SEGS detailers (replaces node 11/14)
|
||||||
workflow["7"]["inputs"]["clip"] = clip_source
|
if character_b:
|
||||||
workflow["11"]["inputs"]["clip"] = clip_source
|
_inject_multi_char_detailers(workflow, prompts, model_source, clip_source)
|
||||||
workflow["13"]["inputs"]["clip"] = clip_source
|
|
||||||
workflow["14"]["inputs"]["clip"] = clip_source
|
# Apply connections to all model/clip consumers (conditional on node existence)
|
||||||
workflow["15"]["inputs"]["clip"] = clip_source
|
for nid in ["3", "11", "13"] + _SEGS_DETAILER_NODES:
|
||||||
|
if nid in workflow:
|
||||||
|
workflow[nid]["inputs"]["model"] = model_source
|
||||||
|
|
||||||
|
for nid in ["6", "7", "11", "13", "14", "15"] + _SEGS_PROMPT_NODES:
|
||||||
|
if nid in workflow:
|
||||||
|
workflow[nid]["inputs"]["clip"] = clip_source
|
||||||
|
|
||||||
# 4. Randomize seeds (or use a fixed seed for reproducible batches like Strengths Gallery)
|
# 4. Randomize seeds (or use a fixed seed for reproducible batches like Strengths Gallery)
|
||||||
gen_seed = fixed_seed if fixed_seed is not None else random.randint(1, 10**15)
|
gen_seed = fixed_seed if fixed_seed is not None else random.randint(1, 10**15)
|
||||||
workflow["3"]["inputs"]["seed"] = gen_seed
|
for nid in ["3", "11", "13"] + _SEGS_DETAILER_NODES:
|
||||||
if "11" in workflow: workflow["11"]["inputs"]["seed"] = gen_seed
|
if nid in workflow:
|
||||||
if "13" in workflow: workflow["13"]["inputs"]["seed"] = gen_seed
|
workflow[nid]["inputs"]["seed"] = gen_seed
|
||||||
|
|
||||||
# 5. Set image dimensions
|
# 5. Set image dimensions
|
||||||
if "5" in workflow:
|
if "5" in workflow:
|
||||||
@@ -321,7 +589,7 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
|
|||||||
# 7. Sync sampler/scheduler from main KSampler to adetailer nodes
|
# 7. Sync sampler/scheduler from main KSampler to adetailer nodes
|
||||||
sampler_name = workflow["3"]["inputs"].get("sampler_name")
|
sampler_name = workflow["3"]["inputs"].get("sampler_name")
|
||||||
scheduler = workflow["3"]["inputs"].get("scheduler")
|
scheduler = workflow["3"]["inputs"].get("scheduler")
|
||||||
for node_id in ["11", "13"]:
|
for node_id in ["11", "13"] + _SEGS_DETAILER_NODES:
|
||||||
if node_id in workflow:
|
if node_id in workflow:
|
||||||
if sampler_name:
|
if sampler_name:
|
||||||
workflow[node_id]["inputs"]["sampler_name"] = sampler_name
|
workflow[node_id]["inputs"]["sampler_name"] = sampler_name
|
||||||
|
|||||||
@@ -1736,3 +1736,47 @@ textarea[readonly] {
|
|||||||
.gallery-grid.selection-mode .gallery-card:hover {
|
.gallery-grid.selection-mode .gallery-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Tag Widgets (Prompt Builder) -------------------------------- */
|
||||||
|
.tag-widget-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-widget {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: var(--font-mono, 'SF Mono', monospace);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-widget.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-widget.inactive {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: var(--border);
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-widget:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--accent-bright);
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,12 +153,29 @@
|
|||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tag-widget-container d-none" id="prompt-tags"></div>
|
||||||
<textarea class="form-control form-control-sm font-monospace" id="prompt-preview"
|
<textarea class="form-control form-control-sm font-monospace" id="prompt-preview"
|
||||||
name="override_prompt" rows="5"
|
name="override_prompt" rows="5"
|
||||||
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
|
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
|
||||||
<div class="form-text" id="preview-status"></div>
|
<div class="form-text" id="preview-status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ADetailer Prompt Previews -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label mb-0">Face Detailer Prompt</label>
|
||||||
|
<div class="tag-widget-container d-none" id="face-tags"></div>
|
||||||
|
<textarea class="form-control form-control-sm font-monospace" id="face-preview"
|
||||||
|
name="override_face_prompt" rows="2"
|
||||||
|
placeholder="Auto-populated on Build — edit to override face detailer prompt."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label mb-0">Hand Detailer Prompt</label>
|
||||||
|
<div class="tag-widget-container d-none" id="hand-tags"></div>
|
||||||
|
<textarea class="form-control form-control-sm font-monospace" id="hand-preview"
|
||||||
|
name="override_hand_prompt" rows="2"
|
||||||
|
placeholder="Auto-populated on Build — edit to override hand detailer prompt."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Additional prompts -->
|
<!-- Additional prompts -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
|
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
|
||||||
@@ -246,7 +263,9 @@
|
|||||||
randomizeCategory(field, key);
|
randomizeCategory(field, key);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('prompt-preview').value = '';
|
clearTagWidgets('prompt-tags', 'prompt-preview');
|
||||||
|
clearTagWidgets('face-tags', 'face-preview');
|
||||||
|
clearTagWidgets('hand-tags', 'hand-preview');
|
||||||
document.getElementById('preview-status').textContent = '';
|
document.getElementById('preview-status').textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +292,51 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Tag Widget System ---
|
||||||
|
function populateTagWidgets(containerId, textareaId, promptStr) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (!promptStr || !promptStr.trim()) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = promptStr.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.className = 'tag-widget active';
|
||||||
|
el.textContent = tag;
|
||||||
|
el.dataset.tag = tag;
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
el.classList.toggle('active');
|
||||||
|
el.classList.toggle('inactive');
|
||||||
|
rebuildFromTags(containerId, textareaId);
|
||||||
|
});
|
||||||
|
container.appendChild(el);
|
||||||
|
});
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
textarea.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildFromTags(containerId, textareaId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
const activeTags = Array.from(container.querySelectorAll('.tag-widget.active'))
|
||||||
|
.map(el => el.dataset.tag);
|
||||||
|
textarea.value = activeTags.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTagWidgets(containerId, textareaId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.classList.add('d-none');
|
||||||
|
textarea.classList.remove('d-none');
|
||||||
|
textarea.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
// --- Prompt preview ---
|
// --- Prompt preview ---
|
||||||
async function buildPromptPreview() {
|
async function buildPromptPreview() {
|
||||||
const charVal = document.getElementById('character').value;
|
const charVal = document.getElementById('character').value;
|
||||||
@@ -288,7 +352,12 @@
|
|||||||
status.textContent = 'Error: ' + data.error;
|
status.textContent = 'Error: ' + data.error;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('prompt-preview').value = data.prompt;
|
document.getElementById('prompt-preview').value = data.prompt;
|
||||||
status.textContent = 'Auto-built — edit freely, or Clear to let the server rebuild on generate.';
|
document.getElementById('face-preview').value = data.face || '';
|
||||||
|
document.getElementById('hand-preview').value = data.hand || '';
|
||||||
|
populateTagWidgets('prompt-tags', 'prompt-preview', data.prompt);
|
||||||
|
populateTagWidgets('face-tags', 'face-preview', data.face || '');
|
||||||
|
populateTagWidgets('hand-tags', 'hand-preview', data.hand || '');
|
||||||
|
status.textContent = 'Click tags to toggle — Clear to reset.';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = 'Request failed.';
|
status.textContent = 'Request failed.';
|
||||||
@@ -297,7 +366,9 @@
|
|||||||
|
|
||||||
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
|
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
|
||||||
document.getElementById('clear-preview-btn').addEventListener('click', () => {
|
document.getElementById('clear-preview-btn').addEventListener('click', () => {
|
||||||
document.getElementById('prompt-preview').value = '';
|
clearTagWidgets('prompt-tags', 'prompt-preview');
|
||||||
|
clearTagWidgets('face-tags', 'face-preview');
|
||||||
|
clearTagWidgets('hand-tags', 'hand-preview');
|
||||||
document.getElementById('preview-status').textContent = '';
|
document.getElementById('preview-status').textContent = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2>Character Library</h2>
|
<h2>Character Library</h2>
|
||||||
<div class="d-flex gap-1 align-items-center">
|
<div class="d-flex gap-1 align-items-center">
|
||||||
|
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||||
<form action="{{ url_for('rescan') }}" method="post" class="d-contents">
|
<form action="{{ url_for('rescan') }}" method="post" class="d-contents">
|
||||||
|
|||||||
@@ -25,8 +25,9 @@
|
|||||||
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
|
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
|
||||||
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
|
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
|
||||||
<div class="vr mx-1 d-none d-lg-block"></div>
|
<div class="vr mx-1 d-none d-lg-block"></div>
|
||||||
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
|
|
||||||
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
|
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
|
||||||
|
<a href="/quick" class="btn btn-sm btn-outline-light">Quick</a>
|
||||||
|
<a href="/multi" class="btn btn-sm btn-outline-light">Multi</a>
|
||||||
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
|
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
|
||||||
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
|
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
|
||||||
<div class="vr mx-1 d-none d-lg-block"></div>
|
<div class="vr mx-1 d-none d-lg-block"></div>
|
||||||
|
|||||||
547
templates/multi_char.html
Normal file
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>
|
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality">{{ extra_negative or '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Resolution override #}
|
||||||
|
{% set res = preset.data.get('resolution', {}) %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Resolution Override</label>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="" data-h="">Preset Default</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1024" data-h="1024">1:1</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1152" data-h="896">4:3 L</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="896" data-h="1152">4:3 P</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1344" data-h="768">16:9 L</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="768" data-h="1344">16:9 P</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1280" data-h="800">16:10 L</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="800" data-h="1280">16:10 P</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<label class="form-label mb-0 small fw-semibold">W</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
|
||||||
|
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
|
||||||
|
<span class="text-muted">×</span>
|
||||||
|
<label class="form-label mb-0 small fw-semibold">H</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
|
||||||
|
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<div class="input-group input-group-sm mb-1">
|
<div class="input-group input-group-sm mb-1">
|
||||||
<span class="input-group-text">Seed</span>
|
<span class="input-group-text">Seed</span>
|
||||||
@@ -131,7 +156,7 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<small class="text-muted fw-semibold d-block mb-1">Identity</small>
|
<small class="text-muted fw-semibold d-block mb-1">Identity</small>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="d-flex flex-wrap gap-1">
|
||||||
{% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %}
|
{% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
|
||||||
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
|
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
|
||||||
<span>{{ k | replace('_', ' ') }}</span>
|
<span>{{ k | replace('_', ' ') }}</span>
|
||||||
{{ toggle_badge(char_fields.identity.get(k, true)) }}
|
{{ toggle_badge(char_fields.identity.get(k, true)) }}
|
||||||
@@ -156,7 +181,7 @@
|
|||||||
<span class="badge bg-light text-dark border ms-1">outfit: {{ wd.get('outfit', 'default') }}</span>
|
<span class="badge bg-light text-dark border ms-1">outfit: {{ wd.get('outfit', 'default') }}</span>
|
||||||
</small>
|
</small>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="d-flex flex-wrap gap-1">
|
||||||
{% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %}
|
{% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
|
||||||
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
|
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
|
||||||
<span>{{ k | replace('_', ' ') }}</span>
|
<span>{{ k | replace('_', ' ') }}</span>
|
||||||
{{ toggle_badge(wd.fields.get(k, true)) }}
|
{{ toggle_badge(wd.fields.get(k, true)) }}
|
||||||
@@ -175,7 +200,7 @@
|
|||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
{% for section, label, field_key, field_keys in [
|
{% for section, label, field_key, field_keys in [
|
||||||
('outfit', 'Outfit', 'outfit_id', []),
|
('outfit', 'Outfit', 'outfit_id', []),
|
||||||
('action', 'Action', 'action_id', ['full_body','additional','head','eyes','arms','hands']),
|
('action', 'Action', 'action_id', ['base','head','upper_body','lower_body','hands','feet','additional']),
|
||||||
('style', 'Style', 'style_id', []),
|
('style', 'Style', 'style_id', []),
|
||||||
('scene', 'Scene', 'scene_id', ['background','foreground','furniture','colors','lighting','theme']),
|
('scene', 'Scene', 'scene_id', ['background','foreground','furniture','colors','lighting','theme']),
|
||||||
('detailer', 'Detailer', 'detailer_id', []),
|
('detailer', 'Detailer', 'detailer_id', []),
|
||||||
@@ -225,6 +250,21 @@
|
|||||||
<div class="card-body py-2">{{ entity_badge(preset.data.get('checkpoint', {}).get('checkpoint_path')) }}</div>
|
<div class="card-body py-2">{{ entity_badge(preset.data.get('checkpoint', {}).get('checkpoint_path')) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% set res = preset.data.get('resolution', {}) %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header py-1"><small class="fw-semibold">Resolution</small></div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
{% if res.get('random', false) %}
|
||||||
|
<span class="badge bg-warning text-dark">Random</span>
|
||||||
|
{% elif res.get('width') %}
|
||||||
|
<span class="badge bg-info text-dark">{{ res.width }} × {{ res.height }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Default</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
@@ -362,6 +402,20 @@ window._onEndlessResult = function(jobResult) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolution preset buttons
|
||||||
|
document.querySelectorAll('.res-preset').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.getElementById('res-width').value = btn.dataset.w;
|
||||||
|
document.getElementById('res-height').value = btn.dataset.h;
|
||||||
|
document.querySelectorAll('.res-preset').forEach(b => {
|
||||||
|
b.classList.remove('btn-secondary');
|
||||||
|
b.classList.add('btn-outline-secondary');
|
||||||
|
});
|
||||||
|
btn.classList.remove('btn-outline-secondary');
|
||||||
|
btn.classList.add('btn-secondary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// JSON editor
|
// JSON editor
|
||||||
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
|
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Identity Fields</label>
|
<label class="form-label fw-semibold">Identity Fields</label>
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
{% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %}
|
{% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
|
||||||
<div class="col-6 col-sm-4 col-md-4">
|
<div class="col-6 col-sm-4 col-md-4">
|
||||||
<div class="d-flex justify-content-between align-items-center border rounded p-2">
|
<div class="d-flex justify-content-between align-items-center border rounded p-2">
|
||||||
<small>{{ k | replace('_', ' ') }}</small>
|
<small>{{ k | replace('_', ' ') }}</small>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
value="{{ wd_cfg.get('outfit', 'default') }}" placeholder="default">
|
value="{{ wd_cfg.get('outfit', 'default') }}" placeholder="default">
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
{% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %}
|
{% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
|
||||||
<div class="col-6 col-sm-4">
|
<div class="col-6 col-sm-4">
|
||||||
<div class="d-flex justify-content-between align-items-center border rounded p-2">
|
<div class="d-flex justify-content-between align-items-center border rounded p-2">
|
||||||
<small>{{ k | replace('_', ' ') }}</small>
|
<small>{{ k | replace('_', ' ') }}</small>
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="form-label fw-semibold">Fields</label>
|
<label class="form-label fw-semibold">Fields</label>
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
{% for k in ['full_body','additional','head','eyes','arms','hands'] %}
|
{% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
|
||||||
<div class="col-6 col-sm-4">
|
<div class="col-6 col-sm-4">
|
||||||
<div class="d-flex justify-content-between align-items-center border rounded p-2">
|
<div class="d-flex justify-content-between align-items-center border rounded p-2">
|
||||||
<small>{{ k | replace('_', ' ') }}</small>
|
<small>{{ k | replace('_', ' ') }}</small>
|
||||||
@@ -266,6 +266,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Resolution -->
|
||||||
|
{% set res = d.get('resolution', {}) %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Resolution</strong>
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" name="res_random" id="res_random" {% if res.get('random', false) %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small" for="res_random">Random aspect ratio</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="res-fields">
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1024" data-h="1024">1:1</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1152" data-h="896">4:3 L</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="896" data-h="1152">4:3 P</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1344" data-h="768">16:9 L</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="768" data-h="1344">16:9 P</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1280" data-h="800">16:10 L</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="800" data-h="1280">16:10 P</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="1792" data-h="768">21:9 L</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary res-preset" data-w="768" data-h="1792">21:9 P</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<label class="form-label mb-0 small fw-semibold">W</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" name="res_width" id="res-width"
|
||||||
|
value="{{ res.get('width', 1024) }}" min="64" max="4096" step="64" style="width:88px">
|
||||||
|
<span class="text-muted">×</span>
|
||||||
|
<label class="form-label mb-0 small fw-semibold">H</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" name="res_height" id="res-height"
|
||||||
|
value="{{ res.get('height', 1024) }}" min="64" max="4096" step="64" style="width:88px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-2 pb-4">
|
<div class="d-flex gap-2 pb-4">
|
||||||
<button type="submit" class="btn btn-primary">Save Preset</button>
|
<button type="submit" class="btn btn-primary">Save Preset</button>
|
||||||
<a href="{{ url_for('preset_detail', slug=preset.slug) }}" class="btn btn-outline-secondary">Cancel</a>
|
<a href="{{ url_for('preset_detail', slug=preset.slug) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
@@ -274,3 +308,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Resolution preset buttons
|
||||||
|
document.querySelectorAll('.res-preset').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.getElementById('res-width').value = btn.dataset.w;
|
||||||
|
document.getElementById('res-height').value = btn.dataset.h;
|
||||||
|
document.querySelectorAll('.res-preset').forEach(b => {
|
||||||
|
b.classList.remove('btn-secondary');
|
||||||
|
b.classList.add('btn-outline-secondary');
|
||||||
|
});
|
||||||
|
btn.classList.remove('btn-outline-secondary');
|
||||||
|
btn.classList.add('btn-secondary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight the matching preset button on load
|
||||||
|
(function() {
|
||||||
|
const w = document.getElementById('res-width').value;
|
||||||
|
const h = document.getElementById('res-height').value;
|
||||||
|
document.querySelectorAll('.res-preset').forEach(btn => {
|
||||||
|
if (btn.dataset.w === w && btn.dataset.h === h) {
|
||||||
|
btn.classList.remove('btn-outline-secondary');
|
||||||
|
btn.classList.add('btn-secondary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle resolution fields visibility based on random checkbox
|
||||||
|
const randomCb = document.getElementById('res_random');
|
||||||
|
const resFields = document.getElementById('res-fields');
|
||||||
|
function toggleResFields() {
|
||||||
|
resFields.querySelectorAll('input, button.res-preset').forEach(el => {
|
||||||
|
el.disabled = randomCb.checked;
|
||||||
|
});
|
||||||
|
resFields.style.opacity = randomCb.checked ? '0.5' : '1';
|
||||||
|
}
|
||||||
|
randomCb.addEventListener('change', toggleResFields);
|
||||||
|
toggleResFields();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
314
templates/quick.html
Normal file
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 {
|
try {
|
||||||
const genResp = await fetch(`/scene/${scene.slug}/generate`, {
|
const genResp = await fetch(`/scene/${scene.slug}/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
body: new URLSearchParams({ action: 'replace', character_slug: '' }),
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
});
|
});
|
||||||
const genData = await genResp.json();
|
const genData = await genResp.json();
|
||||||
|
|||||||
5
utils.py
5
utils.py
@@ -11,8 +11,9 @@ _LORA_DEFAULTS = {
|
|||||||
'detailers': '/ImageModels/lora/Illustrious/Detailers',
|
'detailers': '/ImageModels/lora/Illustrious/Detailers',
|
||||||
}
|
}
|
||||||
|
|
||||||
_IDENTITY_KEYS = ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']
|
_BODY_GROUP_KEYS = ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']
|
||||||
_WARDROBE_KEYS = ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']
|
_IDENTITY_KEYS = _BODY_GROUP_KEYS
|
||||||
|
_WARDROBE_KEYS = _BODY_GROUP_KEYS
|
||||||
|
|
||||||
|
|
||||||
def allowed_file(filename):
|
def allowed_file(filename):
|
||||||
|
|||||||
Reference in New Issue
Block a user