import json
import os
import re
import time
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
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
from services.llm import load_prompt, call_llm
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_outfits')
def get_missing_outfits():
missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.name).all()
return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]}
@app.route('/clear_all_outfit_covers', methods=['POST'])
def clear_all_outfit_covers():
outfits = Outfit.query.all()
for outfit in outfits:
outfit.image_path = None
db.session.commit()
return {'success': True}
@app.route('/outfits')
def outfits_index():
outfits = Outfit.query.order_by(Outfit.name).all()
lora_assignments = _count_outfit_lora_assignments()
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments)
@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):
flash('Clothing LoRA directory not found.', 'error')
return redirect(url_for('outfits_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('outfit_system.txt')
if not system_prompt:
flash('Outfit system prompt file not found.', 'error')
return redirect(url_for('outfits_index'))
for filename in 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_count += 1
continue
html_filename = f"{name_base}.html"
html_path = os.path.join(clothing_lora_dir, html_filename)
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()
clean_html = re.sub(r'', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'
]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML {html_filename}: {e}")
try:
print(f"Asking LLM to describe outfit: {outfit_name}")
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
outfit_data = json.loads(clean_json)
outfit_data['outfit_id'] = outfit_id
outfit_data['outfit_name'] = outfit_name
if 'lora' not in outfit_data:
outfit_data['lora'] = {}
outfit_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not outfit_data['lora'].get('lora_triggers'):
outfit_data['lora']['lora_triggers'] = name_base
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(app.config['CLOTHING_DIR'], exist_ok=True)
with open(json_path, 'w') as f:
json.dump(outfit_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
time.sleep(0.5)
except Exception as e:
print(f"Error creating outfit for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
sync_outfits()
msg = f'Successfully processed outfits: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No outfits created or overwritten. {skipped_count} existing entries found.')
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 (comma separated string to list)
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
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:
print(f"Edit error: {e}")
flash(f"Error saving changes: {str(e)}")
return render_template('outfits/edit.html', outfit=outfit, loras=loras)
@app.route('/outfit//upload', methods=['POST'])
def upload_outfit_image(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
# Create outfit subfolder
outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}")
os.makedirs(outfit_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(outfit_folder, filename)
file.save(file_path)
# Store relative path in DB
outfit.image_path = f"outfits/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('outfit_detail', slug=slug))
@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 _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 _WARDROBE_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:
print(f"Generation error: {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//replace_cover_from_preview', methods=['POST'])
def replace_outfit_cover_from_preview(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
outfit.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/create', methods=['GET', 'POST'])
def create_outfit():
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'
# 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 redirect(request.url)
# Generate JSON with LLM
system_prompt = load_prompt('outfit_system.txt')
if not system_prompt:
flash("System prompt file not found.")
return redirect(request.url)
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:
print(f"LLM error: {e}")
flash(f"Failed to generate outfit profile: {e}")
return redirect(request.url)
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:
print(f"Save error: {e}")
flash(f"Failed to create outfit: {e}")
return redirect(request.url)
return render_template('outfits/create.html')
@app.route('/outfit//save_defaults', methods=['POST'])
def save_outfit_defaults(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
outfit.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this outfit!')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit//clone', methods=['POST'])
def clone_outfit(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
# Find the next available number for the clone
base_id = outfit.outfit_id
# Extract base name without number suffix
import re
match = re.match(r'^(.+?)_(\d+)$', base_id)
if match:
base_name = match.group(1)
current_num = int(match.group(2))
else:
base_name = base_id
current_num = 1
# Find next available number
next_num = current_num + 1
while True:
new_id = f"{base_name}_{next_num:02d}"
new_filename = f"{new_id}.json"
new_path = os.path.join(app.config['CLOTHING_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
# Create new outfit data (copy of original)
new_data = outfit.data.copy()
new_data['outfit_id'] = new_id
new_data['outfit_name'] = f"{outfit.name} (Copy)"
# Save the new JSON file
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
# Create new outfit in database
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_outfit = Outfit(
outfit_id=new_id,
slug=new_slug,
filename=new_filename,
name=new_data['outfit_name'],
data=new_data
)
db.session.add(new_outfit)
db.session.commit()
flash(f'Outfit cloned as "{new_id}"!')
return redirect(url_for('outfit_detail', slug=new_slug))
@app.route('/outfit//save_json', methods=['POST'])
def save_outfit_json(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
outfit.data = new_data
flag_modified(outfit, 'data')
db.session.commit()
if outfit.filename:
file_path = os.path.join(app.config['CLOTHING_DIR'], outfit.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}