Add extra prompts, endless generation, random character default, and small fixes

- 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>
This commit is contained in:
Aodhan Collins
2026-03-13 02:07:16 +00:00
parent 1b8a798c31
commit 5e4348ebc1
170 changed files with 17367 additions and 9781 deletions

873
routes/characters.py Normal file
View File

@@ -0,0 +1,873 @@
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))