import json import os import re import logging from flask import render_template, request, redirect, url_for, flash, session, current_app from werkzeug.utils import secure_filename from sqlalchemy.orm.attributes import flag_modified from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint, Settings, Look from services.workflow import _prepare_workflow, _get_default_checkpoint from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.sync import sync_outfits from services.file_io import get_available_loras, _count_outfit_lora_assignments from utils import allowed_file, _LORA_DEFAULTS, clean_html_text from services.llm import load_prompt, call_llm from routes.shared import register_common_routes, apply_library_filters logger = logging.getLogger('gaze') def register_routes(app): register_common_routes(app, 'outfits') @app.route('/outfits') def outfits_index(): outfits, fav, nsfw = apply_library_filters(Outfit.query, Outfit) lora_assignments = _count_outfit_lora_assignments() return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments, favourite_filter=fav, nsfw_filter=nsfw) @app.route('/outfits/rescan', methods=['POST']) def rescan_outfits(): sync_outfits() flash('Database synced with outfit files.') return redirect(url_for('outfits_index')) @app.route('/outfits/bulk_create', methods=['POST']) def bulk_create_outfits_from_loras(): _s = Settings.query.first() clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/') _lora_subfolder = os.path.basename(clothing_lora_dir) if not os.path.exists(clothing_lora_dir): if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': 'Clothing LoRA directory not found.'}, 400 flash('Clothing LoRA directory not found.', 'error') return redirect(url_for('outfits_index')) overwrite = request.form.get('overwrite') == 'true' system_prompt = load_prompt('outfit_system.txt') if not system_prompt: if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': 'Outfit system prompt file not found.'}, 500 flash('Outfit system prompt file not found.', 'error') return redirect(url_for('outfits_index')) job_ids = [] skipped = 0 for filename in sorted(os.listdir(clothing_lora_dir)): if not filename.endswith('.safetensors'): continue name_base = filename.rsplit('.', 1)[0] outfit_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) outfit_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() json_filename = f"{outfit_id}.json" json_path = os.path.join(app.config['CLOTHING_DIR'], json_filename) is_existing = os.path.exists(json_path) if is_existing and not overwrite: skipped += 1 continue # Read HTML companion file if it exists html_path = os.path.join(clothing_lora_dir, f"{name_base}.html") html_content = "" if os.path.exists(html_path): try: with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf: html_raw = hf.read() html_content = clean_html_text(html_raw) except Exception: pass def make_task(fn, oid, oname, jp, lsf, html_ctx, sys_prompt, is_exist): def task_fn(job): prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{fn}'" if html_ctx: prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_ctx[:3000]}\n###" llm_response = call_llm(prompt, sys_prompt) clean_json = llm_response.replace('```json', '').replace('```', '').strip() outfit_data = json.loads(clean_json) outfit_data['outfit_id'] = oid outfit_data['outfit_name'] = oname if 'lora' not in outfit_data: outfit_data['lora'] = {} outfit_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}" if not outfit_data['lora'].get('lora_triggers'): outfit_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0] if outfit_data['lora'].get('lora_weight') is None: outfit_data['lora']['lora_weight'] = 0.8 if outfit_data['lora'].get('lora_weight_min') is None: outfit_data['lora']['lora_weight_min'] = 0.7 if outfit_data['lora'].get('lora_weight_max') is None: outfit_data['lora']['lora_weight_max'] = 1.0 os.makedirs(os.path.dirname(jp), exist_ok=True) with open(jp, 'w') as f: json.dump(outfit_data, f, indent=2) job['result'] = {'name': oname, 'action': 'overwritten' if is_exist else 'created'} return task_fn job = _enqueue_task( f"Create outfit: {outfit_name}", make_task(filename, outfit_id, outfit_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing) ) job_ids.append(job['id']) # Enqueue a sync task to run after all creates if job_ids: def sync_task(job): sync_outfits() job['result'] = {'synced': True} _enqueue_task("Sync outfits DB", sync_task) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'success': True, 'queued': len(job_ids), 'skipped': skipped} flash(f'Queued {len(job_ids)} outfit creation tasks ({skipped} skipped). Watch progress in the queue.') return redirect(url_for('outfits_index')) def _get_linked_characters_for_outfit(outfit): """Get all characters that have this outfit assigned.""" linked = [] all_chars = Character.query.all() for char in all_chars: if char.assigned_outfit_ids and outfit.outfit_id in char.assigned_outfit_ids: linked.append(char) return linked @app.route('/outfit/') def outfit_detail(slug): outfit = Outfit.query.filter_by(slug=slug).first_or_404() characters = Character.query.order_by(Character.name).all() # Load state from session preferences = session.get(f'prefs_outfit_{slug}') preview_image = session.get(f'preview_outfit_{slug}') selected_character = session.get(f'char_outfit_{slug}') extra_positive = session.get(f'extra_pos_outfit_{slug}', '') extra_negative = session.get(f'extra_neg_outfit_{slug}', '') # List existing preview images upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}") existing_previews = [] if os.path.isdir(upload_dir): files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) existing_previews = [f"outfits/{slug}/{f}" for f in files] # Get linked characters linked_characters = _get_linked_characters_for_outfit(outfit) return render_template('outfits/detail.html', outfit=outfit, characters=characters, preferences=preferences, preview_image=preview_image, selected_character=selected_character, existing_previews=existing_previews, linked_characters=linked_characters, extra_positive=extra_positive, extra_negative=extra_negative) @app.route('/outfit//edit', methods=['GET', 'POST']) def edit_outfit(slug): outfit = Outfit.query.filter_by(slug=slug).first_or_404() loras = get_available_loras('outfits') # Use clothing LoRAs for outfits if request.method == 'POST': try: # 1. Update basic fields outfit.name = request.form.get('outfit_name') # 2. Rebuild the data dictionary new_data = outfit.data.copy() new_data['outfit_name'] = outfit.name # Update outfit_id if provided new_outfit_id = request.form.get('outfit_id', outfit.outfit_id) new_data['outfit_id'] = new_outfit_id # Update wardrobe section if 'wardrobe' in new_data: for key in new_data['wardrobe'].keys(): form_key = f"wardrobe_{key}" if form_key in request.form: new_data['wardrobe'][key] = request.form.get(form_key) # Update lora section if 'lora' in new_data: for key in new_data['lora'].keys(): form_key = f"lora_{key}" if form_key in request.form: val = request.form.get(form_key) if key == 'lora_weight': try: val = float(val) except: val = 0.8 new_data['lora'][key] = val # LoRA weight randomization bounds for bound in ['lora_weight_min', 'lora_weight_max']: val_str = request.form.get(f'lora_{bound}', '').strip() if val_str: try: new_data.setdefault('lora', {})[bound] = float(val_str) except ValueError: pass else: new_data.setdefault('lora', {}).pop(bound, None) # Update Tags (structured dict) new_data['tags'] = { 'outfit_type': request.form.get('tag_outfit_type', '').strip(), 'nsfw': 'tag_nsfw' in request.form, } outfit.is_nsfw = new_data['tags']['nsfw'] outfit.data = new_data flag_modified(outfit, "data") # 3. Write back to JSON file outfit_file = outfit.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', outfit.outfit_id)}.json" file_path = os.path.join(app.config['CLOTHING_DIR'], outfit_file) with open(file_path, 'w') as f: json.dump(new_data, f, indent=2) db.session.commit() flash('Outfit profile updated successfully!') return redirect(url_for('outfit_detail', slug=slug)) except Exception as e: logger.exception("Edit error: %s", e) flash(f"Error saving changes: {str(e)}") return render_template('outfits/edit.html', outfit=outfit, loras=loras) @app.route('/outfit//generate', methods=['POST']) def generate_outfit_image(slug): outfit = Outfit.query.filter_by(slug=slug).first_or_404() try: # Get action type action = request.form.get('action', 'preview') # Get selected fields selected_fields = request.form.getlist('include_field') # Get selected character (if any) character_slug = request.form.get('character_slug', '') character = None character = _resolve_character(character_slug) if character_slug == '__random__' and character: character_slug = character.slug # Get additional prompts extra_positive = request.form.get('extra_positive', '').strip() extra_negative = request.form.get('extra_negative', '').strip() # Save preferences session[f'prefs_outfit_{slug}'] = selected_fields session[f'char_outfit_{slug}'] = character_slug session[f'extra_pos_outfit_{slug}'] = extra_positive session[f'extra_neg_outfit_{slug}'] = extra_negative session.modified = True # Build combined data for prompt building if character: # Combine character identity/defaults with outfit wardrobe combined_data = { 'character_id': character.character_id, 'identity': character.data.get('identity', {}), 'defaults': character.data.get('defaults', {}), 'wardrobe': outfit.data.get('wardrobe', {}), # Use outfit's wardrobe 'styles': character.data.get('styles', {}), # Use character's styles 'lora': outfit.data.get('lora', {}), # Use outfit's lora 'tags': outfit.data.get('tags', []) } # Merge character identity/defaults into selected_fields so they appear in the prompt if selected_fields: _ensure_character_fields(character, selected_fields, include_wardrobe=False, include_defaults=True) else: # 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. from utils import _BODY_GROUP_KEYS for key in _BODY_GROUP_KEYS: if character.data.get('identity', {}).get(key): selected_fields.append(f'identity::{key}') outfit_wardrobe = outfit.data.get('wardrobe', {}) for key in _BODY_GROUP_KEYS: if outfit_wardrobe.get(key): selected_fields.append(f'wardrobe::{key}') selected_fields.append('special::name') if outfit.data.get('lora', {}).get('lora_triggers'): selected_fields.append('lora::lora_triggers') default_fields = character.default_fields else: # Outfit only - no character combined_data = { 'character_id': outfit.outfit_id, 'wardrobe': outfit.data.get('wardrobe', {}), 'lora': outfit.data.get('lora', {}), 'tags': outfit.data.get('tags', []) } default_fields = outfit.default_fields # Parse optional seed seed_val = request.form.get('seed', '').strip() fixed_seed = int(seed_val) if seed_val else None # Queue generation with open('comfy_workflow.json', 'r') as f: workflow = json.load(f) # Build prompts for combined data prompts = build_prompt(combined_data, selected_fields, default_fields) _append_background(prompts, character) if extra_positive: prompts["main"] = f"{prompts['main']}, {extra_positive}" # Prepare workflow - pass both character and outfit for dual LoRA support ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, outfit=outfit, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed) char_label = character.name if character else 'no character' label = f"Outfit: {outfit.name} ({char_label}) – {action}" job = _enqueue_job(label, workflow, _make_finalize('outfits', slug, Outfit, action)) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('outfit_detail', slug=slug)) except Exception as e: logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('outfit_detail', slug=slug)) @app.route('/outfit/create', methods=['GET', 'POST']) def create_outfit(): form_data = {} if request.method == 'POST': name = request.form.get('name') slug = request.form.get('filename', '').strip() prompt = request.form.get('prompt', '') use_llm = request.form.get('use_llm') == 'on' form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm} # Auto-generate slug from name if not provided if not slug: slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') # Validate slug safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) if not safe_slug: safe_slug = 'outfit' # Find available filename (increment if exists) base_slug = safe_slug counter = 1 while os.path.exists(os.path.join(app.config['CLOTHING_DIR'], f"{safe_slug}.json")): safe_slug = f"{base_slug}_{counter}" counter += 1 # Check if LLM generation is requested if use_llm: if not prompt: flash("Description is required when AI generation is enabled.") return render_template('outfits/create.html', form_data=form_data) # Generate JSON with LLM system_prompt = load_prompt('outfit_system.txt') if not system_prompt: flash("System prompt file not found.") return render_template('outfits/create.html', form_data=form_data) try: llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt) # Clean response (remove markdown if present) clean_json = llm_response.replace('```json', '').replace('```', '').strip() outfit_data = json.loads(clean_json) # Enforce IDs outfit_data['outfit_id'] = safe_slug outfit_data['outfit_name'] = name # Ensure required fields exist if 'wardrobe' not in outfit_data: outfit_data['wardrobe'] = { "base": "", "head": "", "upper_body": "", "lower_body": "", "hands": "", "feet": "", "additional": "" } if 'lora' not in outfit_data: outfit_data['lora'] = { "lora_name": "", "lora_weight": 0.8, "lora_triggers": "" } if 'tags' not in outfit_data: outfit_data['tags'] = [] except Exception as e: logger.exception("LLM error: %s", e) flash(f"Failed to generate outfit profile: {e}") return render_template('outfits/create.html', form_data=form_data) else: # Create blank outfit template outfit_data = { "outfit_id": safe_slug, "outfit_name": name, "wardrobe": { "base": "", "head": "", "upper_body": "", "lower_body": "", "hands": "", "feet": "", "additional": "" }, "lora": { "lora_name": "", "lora_weight": 0.8, "lora_triggers": "" }, "tags": [] } try: # Save file file_path = os.path.join(app.config['CLOTHING_DIR'], f"{safe_slug}.json") with open(file_path, 'w') as f: json.dump(outfit_data, f, indent=2) # Add to DB new_outfit = Outfit( outfit_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", name=name, data=outfit_data ) db.session.add(new_outfit) db.session.commit() flash('Outfit created successfully!') return redirect(url_for('outfit_detail', slug=safe_slug)) except Exception as e: logger.exception("Save error: %s", e) flash(f"Failed to create outfit: {e}") return render_template('outfits/create.html', form_data=form_data) return render_template('outfits/create.html', form_data=form_data)