Files
character-browser/routes/characters.py
2026-03-15 17:45:17 +00:00

870 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'] = {
"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'] = []
# 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": prompt,
"head": "",
"upper_body": "",
"lower_body": "",
"hands": "",
"feet": "",
"additional": ""
},
"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', {
'base': '', 'head': '', 'upper_body': '', 'lower_body': '',
'hands': '', 'feet': '', 'additional': ''
})
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))