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

Character Library

+ + Character
diff --git a/templates/layout.html b/templates/layout.html index 6976bea..7439f48 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -25,8 +25,9 @@ Checkpoints Presets
- + Character Generator + Quick + Multi Image Gallery Settings
diff --git a/templates/multi_char.html b/templates/multi_char.html new file mode 100644 index 0000000..e8a3b6f --- /dev/null +++ b/templates/multi_char.html @@ -0,0 +1,547 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+
+
+ +
+
0%
+
+
+ +
+
Multi-Character Generator
+
+ + + +
+ + +
+ Seed + + +
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ {% 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 %} +
+

+ +

+
+
+ +
+ {% for item in cat_items %} + + {% else %} +

No {{ cat_label | lower }} found.

+ {% endfor %} +
+
+
+
+ {% endfor %} +
+
+ + +
+ +
+ + + + + + + +
+
+ + + × + + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ + + +
+ + + +
+ + + +
+ + + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+
+ +
+ +
+
+
Result
+
+
+

Select two characters and click Generate

+
+
+ Generated Result +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/presets/detail.html b/templates/presets/detail.html index e796a49..d46085d 100644 --- a/templates/presets/detail.html +++ b/templates/presets/detail.html @@ -71,6 +71,31 @@
+ {# Resolution override #} + {% set res = preset.data.get('resolution', {}) %} +
+ +
+ + + + + + + + +
+
+ + + × + + +
+
+
Seed @@ -131,7 +156,7 @@
Identity
- {% 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'] %} {{ k | replace('_', ' ') }} {{ toggle_badge(char_fields.identity.get(k, true)) }} @@ -156,7 +181,7 @@ outfit: {{ wd.get('outfit', 'default') }}
- {% 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'] %} {{ k | replace('_', ' ') }} {{ toggle_badge(wd.fields.get(k, true)) }} @@ -175,7 +200,7 @@
{% for section, label, field_key, field_keys in [ ('outfit', 'Outfit', 'outfit_id', []), - ('action', 'Action', 'action_id', ['full_body','additional','head','eyes','arms','hands']), + ('action', 'Action', 'action_id', ['base','head','upper_body','lower_body','hands','feet','additional']), ('style', 'Style', 'style_id', []), ('scene', 'Scene', 'scene_id', ['background','foreground','furniture','colors','lighting','theme']), ('detailer', 'Detailer', 'detailer_id', []), @@ -225,6 +250,21 @@
{{ entity_badge(preset.data.get('checkpoint', {}).get('checkpoint_path')) }}
+ {% set res = preset.data.get('resolution', {}) %} +
+
+
Resolution
+
+ {% if res.get('random', false) %} + Random + {% elif res.get('width') %} + {{ res.width }} × {{ res.height }} + {% else %} + Default + {% endif %} +
+
+
@@ -362,6 +402,20 @@ window._onEndlessResult = function(jobResult) { } }; +// Resolution preset buttons +document.querySelectorAll('.res-preset').forEach(btn => { + btn.addEventListener('click', () => { + document.getElementById('res-width').value = btn.dataset.w; + document.getElementById('res-height').value = btn.dataset.h; + document.querySelectorAll('.res-preset').forEach(b => { + b.classList.remove('btn-secondary'); + b.classList.add('btn-outline-secondary'); + }); + btn.classList.remove('btn-outline-secondary'); + btn.classList.add('btn-secondary'); + }); +}); + // JSON editor initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}"); diff --git a/templates/presets/edit.html b/templates/presets/edit.html index 4257013..9835572 100644 --- a/templates/presets/edit.html +++ b/templates/presets/edit.html @@ -81,7 +81,7 @@
- {% 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'] %}
{{ k | replace('_', ' ') }} @@ -114,7 +114,7 @@ value="{{ wd_cfg.get('outfit', 'default') }}" placeholder="default">
- {% 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'] %}
{{ k | replace('_', ' ') }} @@ -144,7 +144,7 @@
- {% for k in ['full_body','additional','head','eyes','arms','hands'] %} + {% for k in ['base','head','upper_body','lower_body','hands','feet','additional'] %}
{{ k | replace('_', ' ') }} @@ -266,6 +266,40 @@
+ + {% set res = d.get('resolution', {}) %} +
+
+ Resolution +
+ + +
+
+
+
+ + + + + + + + + +
+
+ + + × + + +
+
+
+
Cancel @@ -274,3 +308,45 @@
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/quick.html b/templates/quick.html new file mode 100644 index 0000000..6635fbf --- /dev/null +++ b/templates/quick.html @@ -0,0 +1,314 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+
+
+ +
+
0%
+
+
+ +
+
Quick Generator
+
+
+ + +
+ + +
+ Seed + + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + + + + + + + +
+
+ + + × + + +
+
+ + +
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
+ Result + View Preset +
+
+
+

Select a preset and click Generate

+
+
+ Generated Result +
+
+
+ + +
+
Generated Images
+
+
+

No generated images yet.

+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/scenes/index.html b/templates/scenes/index.html index 0a377e7..66f2a33 100644 --- a/templates/scenes/index.html +++ b/templates/scenes/index.html @@ -125,7 +125,7 @@ try { const genResp = await fetch(`/scene/${scene.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }), + body: new URLSearchParams({ action: 'replace', character_slug: '' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); diff --git a/utils.py b/utils.py index 403c6c5..e203f9c 100644 --- a/utils.py +++ b/utils.py @@ -11,8 +11,9 @@ _LORA_DEFAULTS = { 'detailers': '/ImageModels/lora/Illustrious/Detailers', } -_IDENTITY_KEYS = ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra'] -_WARDROBE_KEYS = ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories'] +_BODY_GROUP_KEYS = ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional'] +_IDENTITY_KEYS = _BODY_GROUP_KEYS +_WARDROBE_KEYS = _BODY_GROUP_KEYS def allowed_file(filename):