- Add extra positive/negative prompt textareas to all 9 detail pages with session persistence - Add Endless generation button to all detail pages (continuous preview generation until stopped) - Default character selector to "Random Character" on all secondary detail pages - Fix queue clear endpoint (remove spurious auth check) - Refactor app.py into routes/ and services/ modules - Update CLAUDE.md with new architecture documentation - Various data file updates and cleanup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
874 lines
37 KiB
Python
874 lines
37 KiB
Python
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
|
||
from flask import flash, jsonify, redirect, render_template, request, session, url_for
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
from werkzeug.utils import secure_filename
|
||
|
||
from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
|
||
from services.file_io import get_available_loras
|
||
from services.job_queue import _enqueue_job, _make_finalize
|
||
from services.llm import call_llm, load_prompt
|
||
from services.prompts import build_prompt
|
||
from services.sync import sync_characters
|
||
from services.workflow import _get_default_checkpoint, _prepare_workflow
|
||
from utils import allowed_file
|
||
|
||
logger = logging.getLogger('gaze')
|
||
|
||
|
||
def register_routes(app):
|
||
|
||
@app.route('/')
|
||
def index():
|
||
characters = Character.query.order_by(Character.name).all()
|
||
return render_template('index.html', characters=characters)
|
||
|
||
@app.route('/rescan', methods=['POST'])
|
||
def rescan():
|
||
sync_characters()
|
||
flash('Database synced with character files.')
|
||
return redirect(url_for('index'))
|
||
|
||
@app.route('/character/<path:slug>')
|
||
def detail(slug):
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
all_outfits = Outfit.query.order_by(Outfit.name).all()
|
||
outfit_map = {o.outfit_id: o for o in all_outfits}
|
||
|
||
# Helper function for template to get outfit by ID
|
||
def get_outfit_by_id(outfit_id):
|
||
return outfit_map.get(outfit_id)
|
||
|
||
# Load state from session
|
||
preferences = session.get(f'prefs_{slug}')
|
||
preview_image = session.get(f'preview_{slug}')
|
||
extra_positive = session.get(f'extra_pos_{slug}', '')
|
||
extra_negative = session.get(f'extra_neg_{slug}', '')
|
||
|
||
return render_template('detail.html', character=character, preferences=preferences, preview_image=preview_image, all_outfits=all_outfits, outfit_map=outfit_map, get_outfit_by_id=get_outfit_by_id, extra_positive=extra_positive, extra_negative=extra_negative)
|
||
|
||
@app.route('/character/<path:slug>/transfer', methods=['GET', 'POST'])
|
||
def transfer_character(slug):
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
|
||
if request.method == 'POST':
|
||
target_type = request.form.get('target_type')
|
||
new_name = request.form.get('new_name', '').strip()
|
||
use_llm = request.form.get('use_llm') == 'on'
|
||
|
||
if not new_name:
|
||
flash('New name is required for transfer')
|
||
return redirect(url_for('transfer_character', slug=slug))
|
||
|
||
# Validate new name length and content
|
||
if len(new_name) > 100:
|
||
flash('New name must be 100 characters or less')
|
||
return redirect(url_for('transfer_character', slug=slug))
|
||
|
||
# Validate target type
|
||
VALID_TARGET_TYPES = {'look', 'outfit', 'action', 'style', 'scene', 'detailer'}
|
||
if target_type not in VALID_TARGET_TYPES:
|
||
flash('Invalid target type')
|
||
return redirect(url_for('transfer_character', slug=slug))
|
||
|
||
# Generate new slug from name
|
||
new_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_name.lower()).strip('_')
|
||
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_slug)
|
||
if not safe_slug:
|
||
safe_slug = 'transferred'
|
||
|
||
# Find available filename
|
||
base_slug = safe_slug
|
||
counter = 1
|
||
target_dir = None
|
||
model_class = None
|
||
|
||
# Map target type to directory and model
|
||
if target_type == 'look':
|
||
target_dir = app.config['LOOKS_DIR']
|
||
model_class = Look
|
||
id_field = 'look_id'
|
||
elif target_type == 'outfit':
|
||
target_dir = app.config['CLOTHING_DIR']
|
||
model_class = Outfit
|
||
id_field = 'outfit_id'
|
||
elif target_type == 'action':
|
||
target_dir = app.config['ACTIONS_DIR']
|
||
model_class = Action
|
||
id_field = 'action_id'
|
||
elif target_type == 'style':
|
||
target_dir = app.config['STYLES_DIR']
|
||
model_class = Style
|
||
id_field = 'style_id'
|
||
elif target_type == 'scene':
|
||
target_dir = app.config['SCENES_DIR']
|
||
model_class = Scene
|
||
id_field = 'scene_id'
|
||
elif target_type == 'detailer':
|
||
target_dir = app.config['DETAILERS_DIR']
|
||
model_class = Detailer
|
||
id_field = 'detailer_id'
|
||
else:
|
||
flash('Invalid target type')
|
||
return redirect(url_for('transfer_character', slug=slug))
|
||
|
||
# Check for existing file
|
||
while os.path.exists(os.path.join(target_dir, f"{safe_slug}.json")):
|
||
safe_slug = f"{base_slug}_{counter}"
|
||
counter += 1
|
||
|
||
if use_llm:
|
||
# Use LLM to regenerate JSON for new type
|
||
try:
|
||
# Create prompt for LLM to convert character to target type
|
||
system_prompt = load_prompt('transfer_system.txt')
|
||
if not system_prompt:
|
||
system_prompt = f"""You are an AI assistant that converts character profiles to {target_type} profiles.
|
||
|
||
Convert the following character profile into a {target_type} profile.
|
||
A {target_type} should focus on {target_type}-specific details.
|
||
Keep the core identity but adapt it for the new context.
|
||
Return only valid JSON with no markdown formatting."""
|
||
|
||
# Prepare character data for LLM
|
||
char_summary = json.dumps(character.data, indent=2)
|
||
llm_prompt = f"""Convert this character profile to a {target_type} profile:
|
||
|
||
Original character name: {character.name}
|
||
Target {target_type} name: {new_name}
|
||
|
||
Character data:
|
||
{char_summary}
|
||
|
||
Create a new {target_type} JSON structure appropriate for {target_type}s."""
|
||
|
||
llm_response = call_llm(llm_prompt, system_prompt)
|
||
|
||
# Clean response
|
||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||
new_data = json.loads(clean_json)
|
||
|
||
# Ensure required fields
|
||
new_data[f'{target_type}_id'] = safe_slug
|
||
new_data[f'{target_type}_name'] = new_name
|
||
|
||
except Exception as e:
|
||
print(f"LLM transfer error: {e}")
|
||
flash(f'Failed to generate {target_type} with AI: {e}')
|
||
return redirect(url_for('transfer_character', slug=slug))
|
||
else:
|
||
# Create blank template for target type
|
||
new_data = {
|
||
f'{target_type}_id': safe_slug,
|
||
f'{target_type}_name': new_name,
|
||
'description': f'Transferred from character: {character.name}',
|
||
'tags': character.data.get('tags', []),
|
||
'lora': character.data.get('lora', {})
|
||
}
|
||
|
||
try:
|
||
# Save new file
|
||
file_path = os.path.join(target_dir, f"{safe_slug}.json")
|
||
with open(file_path, 'w') as f:
|
||
json.dump(new_data, f, indent=2)
|
||
|
||
# Create database entry
|
||
new_entity = model_class(
|
||
**{id_field: safe_slug},
|
||
slug=safe_slug,
|
||
filename=f"{safe_slug}.json",
|
||
name=new_name,
|
||
data=new_data
|
||
)
|
||
db.session.add(new_entity)
|
||
db.session.commit()
|
||
|
||
flash(f'Successfully transferred to {target_type}: {new_name}')
|
||
|
||
# Redirect to new entity's detail page
|
||
if target_type == 'look':
|
||
return redirect(url_for('look_detail', slug=safe_slug))
|
||
elif target_type == 'outfit':
|
||
return redirect(url_for('outfit_detail', slug=safe_slug))
|
||
elif target_type == 'action':
|
||
return redirect(url_for('action_detail', slug=safe_slug))
|
||
elif target_type == 'style':
|
||
return redirect(url_for('style_detail', slug=safe_slug))
|
||
elif target_type == 'scene':
|
||
return redirect(url_for('scene_detail', slug=safe_slug))
|
||
elif target_type == 'detailer':
|
||
return redirect(url_for('detailer_detail', slug=safe_slug))
|
||
|
||
except Exception as e:
|
||
print(f"Transfer save error: {e}")
|
||
flash(f'Failed to save transferred {target_type}: {e}')
|
||
return redirect(url_for('transfer_character', slug=slug))
|
||
|
||
# GET request - show transfer form
|
||
return render_template('transfer.html', character=character)
|
||
|
||
@app.route('/create', methods=['GET', 'POST'])
|
||
def create_character():
|
||
# Form data to preserve on errors
|
||
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'
|
||
outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none'
|
||
existing_outfit_id = request.form.get('existing_outfit_id')
|
||
|
||
# Store form data for re-rendering on error
|
||
form_data = {
|
||
'name': name,
|
||
'filename': slug,
|
||
'prompt': prompt,
|
||
'use_llm': use_llm,
|
||
'outfit_mode': outfit_mode,
|
||
'existing_outfit_id': existing_outfit_id
|
||
}
|
||
|
||
# Check for AJAX request
|
||
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||
|
||
# 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 = 'character'
|
||
|
||
# Find available filename (increment if exists)
|
||
base_slug = safe_slug
|
||
counter = 1
|
||
while os.path.exists(os.path.join(app.config['CHARACTERS_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:
|
||
error_msg = "Description is required when AI generation is enabled."
|
||
if is_ajax:
|
||
return jsonify({'error': error_msg}), 400
|
||
flash(error_msg)
|
||
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
||
|
||
# Step 1: Generate or select outfit first
|
||
default_outfit_id = 'default'
|
||
generated_outfit = None
|
||
|
||
if outfit_mode == 'generate':
|
||
# Generate outfit with LLM
|
||
outfit_slug = f"{safe_slug}_outfit"
|
||
outfit_name = f"{name} - default"
|
||
|
||
outfit_prompt = f"""Generate an outfit for character "{name}".
|
||
The character is described as: {prompt}
|
||
|
||
Create an outfit JSON with wardrobe fields appropriate for this character."""
|
||
|
||
system_prompt = load_prompt('outfit_system.txt')
|
||
if not system_prompt:
|
||
error_msg = "Outfit system prompt file not found."
|
||
if is_ajax:
|
||
return jsonify({'error': error_msg}), 500
|
||
flash(error_msg)
|
||
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
||
|
||
try:
|
||
outfit_response = call_llm(outfit_prompt, system_prompt)
|
||
clean_outfit_json = outfit_response.replace('```json', '').replace('```', '').strip()
|
||
outfit_data = json.loads(clean_outfit_json)
|
||
|
||
# Enforce outfit IDs
|
||
outfit_data['outfit_id'] = outfit_slug
|
||
outfit_data['outfit_name'] = outfit_name
|
||
|
||
# Ensure required fields
|
||
if 'wardrobe' not in outfit_data:
|
||
outfit_data['wardrobe'] = {
|
||
"full_body": "",
|
||
"headwear": "",
|
||
"top": "",
|
||
"bottom": "",
|
||
"legwear": "",
|
||
"footwear": "",
|
||
"hands": "",
|
||
"accessories": ""
|
||
}
|
||
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'] = []
|
||
|
||
# Save the outfit
|
||
outfit_path = os.path.join(app.config['CLOTHING_DIR'], f"{outfit_slug}.json")
|
||
with open(outfit_path, 'w') as f:
|
||
json.dump(outfit_data, f, indent=2)
|
||
|
||
# Create DB entry
|
||
generated_outfit = Outfit(
|
||
outfit_id=outfit_slug,
|
||
slug=outfit_slug,
|
||
name=outfit_name,
|
||
data=outfit_data
|
||
)
|
||
db.session.add(generated_outfit)
|
||
db.session.commit()
|
||
|
||
default_outfit_id = outfit_slug
|
||
logger.info(f"Generated outfit: {outfit_name} for character {name}")
|
||
|
||
except Exception as e:
|
||
print(f"Outfit generation error: {e}")
|
||
# Fall back to default
|
||
default_outfit_id = 'default'
|
||
|
||
elif outfit_mode == 'existing' and existing_outfit_id:
|
||
# Use selected existing outfit
|
||
default_outfit_id = existing_outfit_id
|
||
else:
|
||
# Use default outfit
|
||
default_outfit_id = 'default'
|
||
|
||
# Step 2: Generate character (without wardrobe section)
|
||
char_prompt = f"""Generate a character named "{name}".
|
||
Description: {prompt}
|
||
|
||
Default Outfit: {default_outfit_id}
|
||
|
||
Create a character JSON with identity, styles, and defaults sections.
|
||
Do NOT include a wardrobe section - the outfit is handled separately."""
|
||
|
||
system_prompt = load_prompt('character_system.txt')
|
||
if not system_prompt:
|
||
error_msg = "Character system prompt file not found."
|
||
if is_ajax:
|
||
return jsonify({'error': error_msg}), 500
|
||
flash(error_msg)
|
||
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
||
|
||
try:
|
||
llm_response = call_llm(char_prompt, system_prompt)
|
||
|
||
# Clean response (remove markdown if present)
|
||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||
char_data = json.loads(clean_json)
|
||
|
||
# Enforce IDs
|
||
char_data['character_id'] = safe_slug
|
||
char_data['character_name'] = name
|
||
|
||
# Ensure outfit reference is set
|
||
if 'defaults' not in char_data:
|
||
char_data['defaults'] = {}
|
||
char_data['defaults']['outfit'] = default_outfit_id
|
||
|
||
# Remove any wardrobe section that LLM might have added
|
||
char_data.pop('wardrobe', None)
|
||
|
||
except Exception as e:
|
||
print(f"LLM error: {e}")
|
||
error_msg = f"Failed to generate character profile: {e}"
|
||
if is_ajax:
|
||
return jsonify({'error': error_msg}), 500
|
||
flash(error_msg)
|
||
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
||
else:
|
||
# Non-LLM: Create minimal character template
|
||
char_data = {
|
||
"character_id": safe_slug,
|
||
"character_name": name,
|
||
"identity": {
|
||
"base_specs": prompt,
|
||
"hair": "",
|
||
"eyes": "",
|
||
"hands": "",
|
||
"arms": "",
|
||
"torso": "",
|
||
"pelvis": "",
|
||
"legs": "",
|
||
"feet": "",
|
||
"extra": ""
|
||
},
|
||
"defaults": {
|
||
"expression": "",
|
||
"pose": "",
|
||
"scene": "",
|
||
"outfit": existing_outfit_id if outfit_mode == 'existing' else 'default'
|
||
},
|
||
"styles": {
|
||
"aesthetic": "",
|
||
"primary_color": "",
|
||
"secondary_color": "",
|
||
"tertiary_color": ""
|
||
},
|
||
"lora": {
|
||
"lora_name": "",
|
||
"lora_weight": 1,
|
||
"lora_weight_min": 0.7,
|
||
"lora_weight_max": 1,
|
||
"lora_triggers": ""
|
||
},
|
||
"tags": []
|
||
}
|
||
|
||
try:
|
||
# Save file
|
||
file_path = os.path.join(app.config['CHARACTERS_DIR'], f"{safe_slug}.json")
|
||
with open(file_path, 'w') as f:
|
||
json.dump(char_data, f, indent=2)
|
||
|
||
# Add to DB
|
||
new_char = Character(
|
||
character_id=safe_slug,
|
||
slug=safe_slug,
|
||
filename=f"{safe_slug}.json",
|
||
name=name,
|
||
data=char_data
|
||
)
|
||
|
||
# If outfit was generated, assign it to the character
|
||
if outfit_mode == 'generate' and default_outfit_id != 'default':
|
||
new_char.assigned_outfit_ids = [default_outfit_id]
|
||
db.session.commit()
|
||
|
||
db.session.add(new_char)
|
||
db.session.commit()
|
||
|
||
flash('Character created successfully!')
|
||
return redirect(url_for('detail', slug=safe_slug))
|
||
|
||
except Exception as e:
|
||
print(f"Save error: {e}")
|
||
error_msg = f"Failed to create character: {e}"
|
||
if is_ajax:
|
||
return jsonify({'error': error_msg}), 500
|
||
flash(error_msg)
|
||
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
||
|
||
# GET request: show all outfits for the selector
|
||
all_outfits = Outfit.query.order_by(Outfit.name).all()
|
||
return render_template('create.html', form_data={}, all_outfits=all_outfits)
|
||
|
||
@app.route('/character/<path:slug>/edit', methods=['GET', 'POST'])
|
||
def edit_character(slug):
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
loras = get_available_loras('characters')
|
||
char_looks = Look.query.filter_by(character_id=character.character_id).order_by(Look.name).all()
|
||
|
||
if request.method == 'POST':
|
||
try:
|
||
# 1. Update basic fields
|
||
character.name = request.form.get('character_name')
|
||
|
||
# 2. Rebuild the data dictionary
|
||
new_data = character.data.copy()
|
||
new_data['character_name'] = character.name
|
||
|
||
# Update nested sections (non-wardrobe)
|
||
for section in ['identity', 'defaults', 'styles', 'lora']:
|
||
if section in new_data:
|
||
for key in new_data[section]:
|
||
form_key = f"{section}_{key}"
|
||
if form_key in request.form:
|
||
val = request.form.get(form_key)
|
||
# Handle numeric weight
|
||
if key == 'lora_weight':
|
||
try: val = float(val)
|
||
except: val = 1.0
|
||
new_data[section][key] = val
|
||
|
||
# LoRA weight randomization bounds (new fields not present in existing JSON)
|
||
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)
|
||
|
||
# Handle wardrobe - support both nested and flat formats
|
||
wardrobe = new_data.get('wardrobe', {})
|
||
if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict):
|
||
# New nested format - update each outfit
|
||
for outfit_name in wardrobe.keys():
|
||
for key in wardrobe[outfit_name].keys():
|
||
form_key = f"wardrobe_{outfit_name}_{key}"
|
||
if form_key in request.form:
|
||
wardrobe[outfit_name][key] = request.form.get(form_key)
|
||
new_data['wardrobe'] = wardrobe
|
||
else:
|
||
# Legacy flat format
|
||
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 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]
|
||
|
||
character.data = new_data
|
||
flag_modified(character, "data")
|
||
|
||
# 3. Write back to JSON file
|
||
# Use the filename we stored during sync, or fallback to a sanitized ID
|
||
char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json"
|
||
file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file)
|
||
|
||
with open(file_path, 'w') as f:
|
||
json.dump(new_data, f, indent=2)
|
||
|
||
db.session.commit()
|
||
flash('Character profile updated successfully!')
|
||
return redirect(url_for('detail', slug=slug))
|
||
|
||
except Exception as e:
|
||
print(f"Edit error: {e}")
|
||
flash(f"Error saving changes: {str(e)}")
|
||
|
||
return render_template('edit.html', character=character, loras=loras, char_looks=char_looks)
|
||
|
||
@app.route('/character/<path:slug>/outfit/switch', methods=['POST'])
|
||
def switch_outfit(slug):
|
||
"""Switch the active outfit for a character."""
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
outfit_id = request.form.get('outfit', 'default')
|
||
|
||
# Get available outfits and validate
|
||
available_outfits = character.get_available_outfits()
|
||
available_ids = [o['outfit_id'] for o in available_outfits]
|
||
|
||
if outfit_id in available_ids:
|
||
character.active_outfit = outfit_id
|
||
db.session.commit()
|
||
outfit_name = next((o['name'] for o in available_outfits if o['outfit_id'] == outfit_id), outfit_id)
|
||
flash(f'Switched to "{outfit_name}" outfit.')
|
||
else:
|
||
flash(f'Outfit "{outfit_id}" not found.', 'error')
|
||
|
||
return redirect(url_for('detail', slug=slug))
|
||
|
||
@app.route('/character/<path:slug>/outfit/assign', methods=['POST'])
|
||
def assign_outfit(slug):
|
||
"""Assign an outfit from the Outfit table to this character."""
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
outfit_id = request.form.get('outfit_id')
|
||
|
||
if not outfit_id:
|
||
flash('No outfit selected.', 'error')
|
||
return redirect(url_for('detail', slug=slug))
|
||
|
||
# Check if outfit exists
|
||
outfit = Outfit.query.filter_by(outfit_id=outfit_id).first()
|
||
if not outfit:
|
||
flash(f'Outfit "{outfit_id}" not found.', 'error')
|
||
return redirect(url_for('detail', slug=slug))
|
||
|
||
# Assign outfit
|
||
if character.assign_outfit(outfit_id):
|
||
db.session.commit()
|
||
flash(f'Assigned outfit "{outfit.name}" to {character.name}.')
|
||
else:
|
||
flash(f'Outfit "{outfit.name}" is already assigned.', 'warning')
|
||
|
||
return redirect(url_for('detail', slug=slug))
|
||
|
||
@app.route('/character/<path:slug>/outfit/unassign/<outfit_id>', methods=['POST'])
|
||
def unassign_outfit(slug, outfit_id):
|
||
"""Unassign an outfit from this character."""
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
|
||
if character.unassign_outfit(outfit_id):
|
||
db.session.commit()
|
||
flash('Outfit unassigned.')
|
||
else:
|
||
flash('Outfit was not assigned.', 'warning')
|
||
|
||
return redirect(url_for('detail', slug=slug))
|
||
|
||
@app.route('/character/<path:slug>/outfit/add', methods=['POST'])
|
||
def add_outfit(slug):
|
||
"""Add a new outfit to a character."""
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
outfit_name = request.form.get('outfit_name', '').strip()
|
||
|
||
if not outfit_name:
|
||
flash('Outfit name cannot be empty.', 'error')
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
# Sanitize outfit name for use as key
|
||
safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', outfit_name.lower())
|
||
|
||
# Get wardrobe data
|
||
wardrobe = character.data.get('wardrobe', {})
|
||
|
||
# Ensure wardrobe is in new nested format
|
||
if 'default' not in wardrobe or not isinstance(wardrobe.get('default'), dict):
|
||
# Convert legacy format
|
||
wardrobe = {'default': wardrobe}
|
||
|
||
# Check if outfit already exists
|
||
if safe_name in wardrobe:
|
||
flash(f'Outfit "{safe_name}" already exists.', 'error')
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
# Create new outfit (copy from default as template)
|
||
default_outfit = wardrobe.get('default', {
|
||
'headwear': '', 'top': '', 'legwear': '',
|
||
'footwear': '', 'hands': '', 'accessories': ''
|
||
})
|
||
wardrobe[safe_name] = default_outfit.copy()
|
||
|
||
# Update character data
|
||
character.data['wardrobe'] = wardrobe
|
||
flag_modified(character, 'data')
|
||
|
||
# Save to JSON file
|
||
char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json"
|
||
file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file)
|
||
with open(file_path, 'w') as f:
|
||
json.dump(character.data, f, indent=2)
|
||
|
||
db.session.commit()
|
||
flash(f'Added new outfit "{safe_name}".')
|
||
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
@app.route('/character/<path:slug>/outfit/delete', methods=['POST'])
|
||
def delete_outfit(slug):
|
||
"""Delete an outfit from a character."""
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
outfit_name = request.form.get('outfit', '')
|
||
|
||
wardrobe = character.data.get('wardrobe', {})
|
||
|
||
# Cannot delete default
|
||
if outfit_name == 'default':
|
||
flash('Cannot delete the default outfit.', 'error')
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
if outfit_name not in wardrobe:
|
||
flash(f'Outfit "{outfit_name}" not found.', 'error')
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
# Delete outfit
|
||
del wardrobe[outfit_name]
|
||
character.data['wardrobe'] = wardrobe
|
||
flag_modified(character, 'data')
|
||
|
||
# Switch active outfit if deleted was active
|
||
if character.active_outfit == outfit_name:
|
||
character.active_outfit = 'default'
|
||
|
||
# Save to JSON file
|
||
char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json"
|
||
file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file)
|
||
with open(file_path, 'w') as f:
|
||
json.dump(character.data, f, indent=2)
|
||
|
||
db.session.commit()
|
||
flash(f'Deleted outfit "{outfit_name}".')
|
||
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
@app.route('/character/<path:slug>/outfit/rename', methods=['POST'])
|
||
def rename_outfit(slug):
|
||
"""Rename an outfit."""
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
old_name = request.form.get('old_name', '')
|
||
new_name = request.form.get('new_name', '').strip()
|
||
|
||
if not new_name:
|
||
flash('New name cannot be empty.', 'error')
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
# Sanitize new name
|
||
safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', new_name.lower())
|
||
|
||
wardrobe = character.data.get('wardrobe', {})
|
||
|
||
if old_name not in wardrobe:
|
||
flash(f'Outfit "{old_name}" not found.', 'error')
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
if safe_name in wardrobe and safe_name != old_name:
|
||
flash(f'Outfit "{safe_name}" already exists.', 'error')
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
# Rename (copy to new key, delete old)
|
||
wardrobe[safe_name] = wardrobe.pop(old_name)
|
||
character.data['wardrobe'] = wardrobe
|
||
flag_modified(character, 'data')
|
||
|
||
# Update active outfit if renamed was active
|
||
if character.active_outfit == old_name:
|
||
character.active_outfit = safe_name
|
||
|
||
# Save to JSON file
|
||
char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json"
|
||
file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file)
|
||
with open(file_path, 'w') as f:
|
||
json.dump(character.data, f, indent=2)
|
||
|
||
db.session.commit()
|
||
flash(f'Renamed outfit "{old_name}" to "{safe_name}".')
|
||
|
||
return redirect(url_for('edit_character', slug=slug))
|
||
|
||
@app.route('/character/<path:slug>/upload', methods=['POST'])
|
||
def upload_image(slug):
|
||
character = Character.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 character subfolder
|
||
char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{slug}")
|
||
os.makedirs(char_folder, exist_ok=True)
|
||
|
||
filename = secure_filename(file.filename)
|
||
file_path = os.path.join(char_folder, filename)
|
||
file.save(file_path)
|
||
|
||
# Store relative path in DB
|
||
character.image_path = f"characters/{slug}/{filename}"
|
||
db.session.commit()
|
||
flash('Image uploaded successfully!')
|
||
|
||
return redirect(url_for('detail', slug=slug))
|
||
|
||
@app.route('/character/<path:slug>/replace_cover_from_preview', methods=['POST'])
|
||
def replace_cover_from_preview(slug):
|
||
character = Character.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)):
|
||
character.image_path = preview_path
|
||
db.session.commit()
|
||
flash('Cover image updated!')
|
||
else:
|
||
flash('No valid preview image selected.', 'error')
|
||
return redirect(url_for('detail', slug=slug))
|
||
|
||
@app.route('/get_missing_characters')
|
||
def get_missing_characters():
|
||
missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.name).all()
|
||
return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]}
|
||
|
||
@app.route('/clear_all_covers', methods=['POST'])
|
||
def clear_all_covers():
|
||
characters = Character.query.all()
|
||
for char in characters:
|
||
char.image_path = None
|
||
db.session.commit()
|
||
return {'success': True}
|
||
|
||
@app.route('/generate_missing', methods=['POST'])
|
||
def generate_missing():
|
||
missing = Character.query.filter(
|
||
(Character.image_path == None) | (Character.image_path == '')
|
||
).order_by(Character.name).all()
|
||
|
||
if not missing:
|
||
flash("No characters missing cover images.")
|
||
return redirect(url_for('index'))
|
||
|
||
enqueued = 0
|
||
for character in missing:
|
||
try:
|
||
with open('comfy_workflow.json', 'r') as f:
|
||
workflow = json.load(f)
|
||
prompts = build_prompt(character.data, None, character.default_fields, character.active_outfit)
|
||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||
workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
|
||
|
||
_slug = character.slug
|
||
_enqueue_job(f"{character.name} – cover", workflow, _make_finalize('characters', _slug, Character))
|
||
enqueued += 1
|
||
except Exception as e:
|
||
print(f"Error queuing cover generation for {character.name}: {e}")
|
||
|
||
flash(f"Queued {enqueued} cover image generation job{'s' if enqueued != 1 else ''}.")
|
||
return redirect(url_for('index'))
|
||
|
||
@app.route('/character/<path:slug>/generate', methods=['POST'])
|
||
def generate_image(slug):
|
||
character = Character.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 additional prompts
|
||
extra_positive = request.form.get('extra_positive', '').strip()
|
||
extra_negative = request.form.get('extra_negative', '').strip()
|
||
|
||
# Save preferences
|
||
session[f'prefs_{slug}'] = selected_fields
|
||
session[f'extra_pos_{slug}'] = extra_positive
|
||
session[f'extra_neg_{slug}'] = extra_negative
|
||
session.modified = True
|
||
|
||
# Parse optional seed
|
||
seed_val = request.form.get('seed', '').strip()
|
||
fixed_seed = int(seed_val) if seed_val else None
|
||
|
||
# Build workflow
|
||
with open('comfy_workflow.json', 'r') as f:
|
||
workflow = json.load(f)
|
||
prompts = build_prompt(character.data, selected_fields, character.default_fields, character.active_outfit)
|
||
|
||
if extra_positive:
|
||
prompts["main"] = f"{prompts['main']}, {extra_positive}"
|
||
|
||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||
workflow = _prepare_workflow(workflow, character, prompts, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed)
|
||
|
||
label = f"{character.name} – {action}"
|
||
job = _enqueue_job(label, workflow, _make_finalize('characters', slug, Character, action))
|
||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||
return {'status': 'queued', 'job_id': job['id']}
|
||
return redirect(url_for('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('detail', slug=slug))
|
||
|
||
@app.route('/character/<path:slug>/save_defaults', methods=['POST'])
|
||
def save_defaults(slug):
|
||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||
selected_fields = request.form.getlist('include_field')
|
||
character.default_fields = selected_fields
|
||
db.session.commit()
|
||
flash('Default prompt selection saved for this character!')
|
||
return redirect(url_for('detail', slug=slug))
|