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

33
routes/__init__.py Normal file
View File

@@ -0,0 +1,33 @@
def register_routes(app):
"""Register all route modules with the Flask app."""
from routes import queue_api
from routes import settings
from routes import characters
from routes import outfits
from routes import actions
from routes import styles
from routes import scenes
from routes import detailers
from routes import checkpoints
from routes import looks
from routes import presets
from routes import generator
from routes import gallery
from routes import strengths
from routes import transfer
queue_api.register_routes(app)
settings.register_routes(app)
characters.register_routes(app)
outfits.register_routes(app)
actions.register_routes(app)
styles.register_routes(app)
scenes.register_routes(app)
detailers.register_routes(app)
checkpoints.register_routes(app)
looks.register_routes(app)
presets.register_routes(app)
generator.register_routes(app)
gallery.register_routes(app)
strengths.register_routes(app)
transfer.register_routes(app)

617
routes/actions.py Normal file
View File

@@ -0,0 +1,617 @@
import json
import os
import re
import time
import random
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, Action, Outfit, 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_actions
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_actions')
def get_missing_actions():
missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.name).all()
return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]}
@app.route('/clear_all_action_covers', methods=['POST'])
def clear_all_action_covers():
actions = Action.query.all()
for action in actions:
action.image_path = None
db.session.commit()
return {'success': True}
@app.route('/actions')
def actions_index():
actions = Action.query.order_by(Action.name).all()
return render_template('actions/index.html', actions=actions)
@app.route('/actions/rescan', methods=['POST'])
def rescan_actions():
sync_actions()
flash('Database synced with action files.')
return redirect(url_for('actions_index'))
@app.route('/action/<path:slug>')
def action_detail(slug):
action = Action.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_action_{slug}')
preview_image = session.get(f'preview_action_{slug}')
selected_character = session.get(f'char_action_{slug}')
extra_positive = session.get(f'extra_pos_action_{slug}', '')
extra_negative = session.get(f'extra_neg_action_{slug}', '')
# List existing preview images
upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{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"actions/{slug}/{f}" for f in files]
return render_template('actions/detail.html', action=action, characters=characters,
preferences=preferences, preview_image=preview_image,
selected_character=selected_character, existing_previews=existing_previews,
extra_positive=extra_positive, extra_negative=extra_negative)
@app.route('/action/<path:slug>/edit', methods=['GET', 'POST'])
def edit_action(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
loras = get_available_loras('actions')
if request.method == 'POST':
try:
# 1. Update basic fields
action.name = request.form.get('action_name')
# 2. Rebuild the data dictionary
new_data = action.data.copy()
new_data['action_name'] = action.name
# Update action_id if provided
new_action_id = request.form.get('action_id', action.action_id)
new_data['action_id'] = new_action_id
# Update action section
if 'action' in new_data:
for key in new_data['action'].keys():
form_key = f"action_{key}"
if form_key in request.form:
new_data['action'][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 = 1.0
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]
action.data = new_data
flag_modified(action, "data")
# 3. Write back to JSON file
action_file = action.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', action.action_id)}.json"
file_path = os.path.join(app.config['ACTIONS_DIR'], action_file)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
db.session.commit()
flash('Action profile updated successfully!')
return redirect(url_for('action_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
flash(f"Error saving changes: {str(e)}")
return render_template('actions/edit.html', action=action, loras=loras)
@app.route('/action/<path:slug>/upload', methods=['POST'])
def upload_action_image(slug):
action = Action.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 action subfolder
action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}")
os.makedirs(action_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(action_folder, filename)
file.save(file_path)
# Store relative path in DB
action.image_path = f"actions/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/generate', methods=['POST'])
def generate_action_image(slug):
action_obj = Action.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'char_action_{slug}'] = character_slug
session[f'prefs_action_{slug}'] = selected_fields
session[f'extra_pos_action_{slug}'] = extra_positive
session[f'extra_neg_action_{slug}'] = extra_negative
session.modified = True
# Build combined data for prompt building
if character:
# Combine character identity/wardrobe with action details
# Action details replace character's 'defaults' (pose, etc.)
combined_data = character.data.copy()
# Update 'defaults' with action details
action_data = action_obj.data.get('action', {})
combined_data['action'] = action_data # Ensure action section is present for routing
combined_data['participants'] = action_obj.data.get('participants', {}) # Add participants
# Aggregate pose-related fields into 'pose'
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet']
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
# Aggregate expression-related fields into 'expression'
expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)]
combined_data['defaults'] = {
'pose': ", ".join(pose_parts),
'expression': ", ".join(expression_parts),
'scene': action_data.get('additional', '')
}
# Merge lora triggers if present
action_lora = action_obj.data.get('lora', {})
if action_lora.get('lora_triggers'):
if 'lora' not in combined_data: combined_data['lora'] = {}
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}"
# Merge tags
combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', [])))
# Use action's defaults if no manual selection
if not selected_fields:
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
# Auto-include essential character fields if a character is selected
if selected_fields:
_ensure_character_fields(character, selected_fields)
else:
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
# Add identity fields
for key in ['base_specs', 'hair', 'eyes']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
# Add wardrobe fields
wardrobe = character.get_active_wardrobe()
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']:
if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}')
default_fields = action_obj.default_fields
active_outfit = character.active_outfit
else:
# Action only - no character (rarely makes sense for actions but let's handle it)
action_data = action_obj.data.get('action', {})
# Aggregate pose-related fields into 'pose'
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet']
pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)]
# Aggregate expression-related fields into 'expression'
expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)]
combined_data = {
'character_id': action_obj.action_id,
'defaults': {
'pose': ", ".join(pose_parts),
'expression': ", ".join(expression_parts),
'scene': action_data.get('additional', '')
},
'lora': action_obj.data.get('lora', {}),
'tags': action_obj.data.get('tags', [])
}
if not selected_fields:
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags']
default_fields = action_obj.default_fields
active_outfit = 'default'
# 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, active_outfit=active_outfit)
# Handle multiple female characters
participants = action_obj.data.get('participants', {})
orientation = participants.get('orientation', '')
f_count = orientation.upper().count('F')
if f_count > 1:
# We need f_count - 1 additional characters
num_extras = f_count - 1
# Get all characters excluding the current one
query = Character.query
if character:
query = query.filter(Character.id != character.id)
all_others = query.all()
if len(all_others) >= num_extras:
extras = random.sample(all_others, num_extras)
for extra_char in extras:
extra_parts = []
# Identity
ident = extra_char.data.get('identity', {})
for key in ['base_specs', 'hair', 'eyes', 'extra']:
val = ident.get(key)
if val:
# Remove 1girl/solo
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
extra_parts.append(val)
# Wardrobe (active outfit)
wardrobe = extra_char.get_active_wardrobe()
for key in ['top', 'headwear', 'legwear', 'footwear', 'accessories']:
val = wardrobe.get(key)
if val:
extra_parts.append(val)
# Append to main prompt
if extra_parts:
prompts["main"] += ", " + ", ".join(extra_parts)
print(f"Added extra character: {extra_char.name}")
_append_background(prompts, character)
if extra_positive:
prompts["main"] = f"{prompts['main']}, {extra_positive}"
# Parse optional seed
seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None
# Prepare workflow
ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, 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"Action: {action_obj.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('action_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('action_detail', slug=slug))
@app.route('/action/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_action_cover_from_preview(slug):
action = Action.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)):
action.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/save_defaults', methods=['POST'])
def save_action_defaults(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
action.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this action!')
return redirect(url_for('action_detail', slug=slug))
@app.route('/actions/bulk_create', methods=['POST'])
def bulk_create_actions_from_loras():
_s = Settings.query.first()
actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/')
_lora_subfolder = os.path.basename(actions_lora_dir)
if not os.path.exists(actions_lora_dir):
flash('Actions LoRA directory not found.', 'error')
return redirect(url_for('actions_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('action_system.txt')
if not system_prompt:
flash('Action system prompt file not found.', 'error')
return redirect(url_for('actions_index'))
for filename in os.listdir(actions_lora_dir):
if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0]
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{action_id}.json"
json_path = os.path.join(app.config['ACTIONS_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(actions_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()
# Strip HTML tags but keep text content for LLM context
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', 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 action: {action_name}")
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt)
# Clean response
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
action_data = json.loads(clean_json)
# Enforce system values while preserving LLM-extracted metadata
action_data['action_id'] = action_id
action_data['action_name'] = action_name
# Update lora dict safely
if 'lora' not in action_data: action_data['lora'] = {}
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
# Fallbacks if LLM failed to extract metadata
if not action_data['lora'].get('lora_triggers'):
action_data['lora']['lora_triggers'] = name_base
if action_data['lora'].get('lora_weight') is None:
action_data['lora']['lora_weight'] = 1.0
if action_data['lora'].get('lora_weight_min') is None:
action_data['lora']['lora_weight_min'] = 0.7
if action_data['lora'].get('lora_weight_max') is None:
action_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f:
json.dump(action_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
except Exception as e:
print(f"Error creating action for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
sync_actions()
msg = f'Successfully processed actions: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No actions created or overwritten. {skipped_count} existing actions found.')
return redirect(url_for('actions_index'))
@app.route('/action/create', methods=['GET', 'POST'])
def create_action():
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'
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug)
if not safe_slug:
safe_slug = 'action'
base_slug = safe_slug
counter = 1
while os.path.exists(os.path.join(app.config['ACTIONS_DIR'], f"{safe_slug}.json")):
safe_slug = f"{base_slug}_{counter}"
counter += 1
if use_llm:
if not prompt:
flash("Description is required when AI generation is enabled.")
return redirect(request.url)
system_prompt = load_prompt('action_system.txt')
if not system_prompt:
flash("Action system prompt file not found.")
return redirect(request.url)
try:
llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
action_data = json.loads(clean_json)
action_data['action_id'] = safe_slug
action_data['action_name'] = name
except Exception as e:
print(f"LLM error: {e}")
flash(f"Failed to generate action profile: {e}")
return redirect(request.url)
else:
action_data = {
"action_id": safe_slug,
"action_name": name,
"action": {
"full_body": "", "head": "", "eyes": "", "arms": "", "hands": "",
"torso": "", "pelvis": "", "legs": "", "feet": "", "additional": ""
},
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
"tags": []
}
try:
file_path = os.path.join(app.config['ACTIONS_DIR'], f"{safe_slug}.json")
with open(file_path, 'w') as f:
json.dump(action_data, f, indent=2)
new_action = Action(
action_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json",
name=name, data=action_data
)
db.session.add(new_action)
db.session.commit()
flash('Action created successfully!')
return redirect(url_for('action_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create action: {e}")
return redirect(request.url)
return render_template('actions/create.html')
@app.route('/action/<path:slug>/clone', methods=['POST'])
def clone_action(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
# Find the next available number for the clone
base_id = action.action_id
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
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['ACTIONS_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = action.data.copy()
new_data['action_id'] = new_id
new_data['action_name'] = f"{action.name} (Copy)"
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_action = Action(
action_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['action_name'], data=new_data
)
db.session.add(new_action)
db.session.commit()
flash(f'Action cloned as "{new_id}"!')
return redirect(url_for('action_detail', slug=new_slug))
@app.route('/action/<path:slug>/save_json', methods=['POST'])
def save_action_json(slug):
action = Action.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
action.data = new_data
flag_modified(action, 'data')
db.session.commit()
if action.filename:
file_path = os.path.join(app.config['ACTIONS_DIR'], action.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}

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))

286
routes/checkpoints.py Normal file
View File

@@ -0,0 +1,286 @@
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, Checkpoint, Character, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts
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_checkpoints, _default_checkpoint_data
from services.file_io import get_available_checkpoints
from services.llm import load_prompt, call_llm
from utils import allowed_file
logger = logging.getLogger('gaze')
def register_routes(app):
def _build_checkpoint_workflow(ckpt_obj, character=None, fixed_seed=None, extra_positive=None, extra_negative=None):
"""Build and return a prepared ComfyUI workflow dict for a checkpoint generation."""
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
if character:
combined_data = character.data.copy()
combined_data['character_id'] = character.character_id
selected_fields = []
for key in ['base_specs', 'hair', 'eyes']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
selected_fields.append('special::name')
wardrobe = character.get_active_wardrobe()
for key in ['full_body', 'top', 'bottom']:
if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}')
prompts = build_prompt(combined_data, selected_fields, None, active_outfit=character.active_outfit)
_append_background(prompts, character)
else:
prompts = {
"main": "masterpiece, best quality, 1girl, solo, simple background, looking at viewer",
"face": "masterpiece, best quality",
"hand": "masterpiece, best quality",
}
if extra_positive:
prompts["main"] = f"{prompts['main']}, {extra_positive}"
workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_obj.checkpoint_path,
checkpoint_data=ckpt_obj.data or {}, custom_negative=extra_negative or None, fixed_seed=fixed_seed)
return workflow
@app.route('/checkpoints')
def checkpoints_index():
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
return render_template('checkpoints/index.html', checkpoints=checkpoints)
@app.route('/checkpoints/rescan', methods=['POST'])
def rescan_checkpoints():
sync_checkpoints()
flash('Checkpoint list synced from disk.')
return redirect(url_for('checkpoints_index'))
@app.route('/checkpoint/<path:slug>')
def checkpoint_detail(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
characters = Character.query.order_by(Character.name).all()
preview_image = session.get(f'preview_checkpoint_{slug}')
selected_character = session.get(f'char_checkpoint_{slug}')
extra_positive = session.get(f'extra_pos_checkpoint_{slug}', '')
extra_negative = session.get(f'extra_neg_checkpoint_{slug}', '')
# List existing preview images
upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{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"checkpoints/{slug}/{f}" for f in files]
return render_template('checkpoints/detail.html', ckpt=ckpt, characters=characters,
preview_image=preview_image, selected_character=selected_character,
existing_previews=existing_previews,
extra_positive=extra_positive, extra_negative=extra_negative)
@app.route('/checkpoint/<path:slug>/upload', methods=['POST'])
def upload_checkpoint_image(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(url_for('checkpoint_detail', slug=slug))
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(url_for('checkpoint_detail', slug=slug))
if file and allowed_file(file.filename):
folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}")
os.makedirs(folder, exist_ok=True)
filename = secure_filename(file.filename)
file.save(os.path.join(folder, filename))
ckpt.image_path = f"checkpoints/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/generate', methods=['POST'])
def generate_checkpoint_image(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
try:
character_slug = request.form.get('character_slug', '')
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()
session[f'char_checkpoint_{slug}'] = character_slug
session[f'extra_pos_checkpoint_{slug}'] = extra_positive
session[f'extra_neg_checkpoint_{slug}'] = extra_negative
session.modified = True
seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None
workflow = _build_checkpoint_workflow(ckpt, character, fixed_seed=fixed_seed, extra_positive=extra_positive, extra_negative=extra_negative)
char_label = character.name if character else 'random'
label = f"Checkpoint: {ckpt.name} ({char_label})"
job = _enqueue_job(label, workflow, _make_finalize('checkpoints', slug, Checkpoint))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('checkpoint_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('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_checkpoint_cover_from_preview(slug):
ckpt = Checkpoint.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)):
ckpt.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/save_json', methods=['POST'])
def save_checkpoint_json(slug):
ckpt = Checkpoint.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
ckpt.data = new_data
flag_modified(ckpt, 'data')
db.session.commit()
checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints')
file_path = os.path.join(checkpoints_dir, f'{ckpt.slug}.json')
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/get_missing_checkpoints')
def get_missing_checkpoints():
missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.name).all()
return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]}
@app.route('/clear_all_checkpoint_covers', methods=['POST'])
def clear_all_checkpoint_covers():
for ckpt in Checkpoint.query.all():
ckpt.image_path = None
db.session.commit()
return {'success': True}
@app.route('/checkpoints/bulk_create', methods=['POST'])
def bulk_create_checkpoints():
checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints')
os.makedirs(checkpoints_dir, exist_ok=True)
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('checkpoint_system.txt')
if not system_prompt:
flash('Checkpoint system prompt file not found.', 'error')
return redirect(url_for('checkpoints_index'))
dirs = [
(app.config.get('ILLUSTRIOUS_MODELS_DIR', ''), 'Illustrious'),
(app.config.get('NOOB_MODELS_DIR', ''), 'Noob'),
]
for dirpath, family in dirs:
if not dirpath or not os.path.exists(dirpath):
continue
for filename in sorted(os.listdir(dirpath)):
if not (filename.endswith('.safetensors') or filename.endswith('.ckpt')):
continue
checkpoint_path = f"{family}/{filename}"
name_base = filename.rsplit('.', 1)[0]
safe_id = re.sub(r'[^a-zA-Z0-9_]', '_', checkpoint_path.rsplit('.', 1)[0]).lower().strip('_')
json_filename = f"{safe_id}.json"
json_path = os.path.join(checkpoints_dir, json_filename)
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
continue
# Look for a matching HTML file alongside the model file
html_path = os.path.join(dirpath, f"{name_base}.html")
html_content = ""
if os.path.exists(html_path):
try:
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
html_raw = hf.read()
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML for {filename}: {e}")
defaults = _default_checkpoint_data(checkpoint_path, filename)
if html_content:
try:
print(f"Asking LLM to describe checkpoint: {filename}")
prompt = (
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
f"(checkpoint_path: '{checkpoint_path}').\n\n"
f"Here 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()
ckpt_data = json.loads(clean_json)
# Enforce fixed fields
ckpt_data['checkpoint_path'] = checkpoint_path
ckpt_data['checkpoint_name'] = filename
# Fill missing fields with defaults
for key, val in defaults.items():
if key not in ckpt_data or ckpt_data[key] is None:
ckpt_data[key] = val
time.sleep(0.5)
except Exception as e:
print(f"LLM error for {filename}: {e}. Using defaults.")
ckpt_data = defaults
else:
ckpt_data = defaults
try:
with open(json_path, 'w') as f:
json.dump(ckpt_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
except Exception as e:
print(f"Error saving JSON for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
sync_checkpoints()
msg = f'Successfully processed checkpoints: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No checkpoints created or overwritten. {skipped_count} existing entries found.')
return redirect(url_for('checkpoints_index'))

457
routes/detailers.py Normal file
View File

@@ -0,0 +1,457 @@
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, Detailer, Action, Outfit, Style, Scene, 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_detailers
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS
logger = logging.getLogger('gaze')
def register_routes(app):
def _queue_detailer_generation(detailer_obj, character=None, selected_fields=None, client_id=None, action=None, extra_positive=None, extra_negative=None, fixed_seed=None):
if character:
combined_data = character.data.copy()
combined_data['character_id'] = character.character_id
# Merge detailer prompt into character's tags
detailer_prompt = detailer_obj.data.get('prompt', '')
if detailer_prompt:
if 'tags' not in combined_data: combined_data['tags'] = []
combined_data['tags'].append(detailer_prompt)
# Merge detailer lora triggers if present
detailer_lora = detailer_obj.data.get('lora', {})
if detailer_lora.get('lora_triggers'):
if 'lora' not in combined_data: combined_data['lora'] = {}
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {detailer_lora['lora_triggers']}"
# Merge character identity and wardrobe fields into selected_fields
if selected_fields:
_ensure_character_fields(character, selected_fields)
else:
# Auto-include essential character fields (minimal set for batch/default generation)
selected_fields = []
for key in ['base_specs', 'hair', 'eyes']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
selected_fields.append('special::name')
wardrobe = character.get_active_wardrobe()
for key in _WARDROBE_KEYS:
if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}')
selected_fields.extend(['special::tags', 'lora::lora_triggers'])
default_fields = detailer_obj.default_fields
active_outfit = character.active_outfit
else:
# Detailer only - no character
detailer_prompt = detailer_obj.data.get('prompt', '')
detailer_tags = [detailer_prompt] if detailer_prompt else []
combined_data = {
'character_id': detailer_obj.detailer_id,
'tags': detailer_tags,
'lora': detailer_obj.data.get('lora', {}),
}
if not selected_fields:
selected_fields = ['special::tags', 'lora::lora_triggers']
default_fields = detailer_obj.default_fields
active_outfit = 'default'
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
_append_background(prompts, character)
if extra_positive:
prompts["main"] = f"{prompts['main']}, {extra_positive}"
ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, detailer=detailer_obj, action=action, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed)
return workflow
@app.route('/detailers')
def detailers_index():
detailers = Detailer.query.order_by(Detailer.name).all()
return render_template('detailers/index.html', detailers=detailers)
@app.route('/detailers/rescan', methods=['POST'])
def rescan_detailers():
sync_detailers()
flash('Database synced with detailer files.')
return redirect(url_for('detailers_index'))
@app.route('/detailer/<path:slug>')
def detailer_detail(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
characters = Character.query.order_by(Character.name).all()
actions = Action.query.order_by(Action.name).all()
# Load state from session
preferences = session.get(f'prefs_detailer_{slug}')
preview_image = session.get(f'preview_detailer_{slug}')
selected_character = session.get(f'char_detailer_{slug}')
selected_action = session.get(f'action_detailer_{slug}')
extra_positive = session.get(f'extra_pos_detailer_{slug}', '')
extra_negative = session.get(f'extra_neg_detailer_{slug}', '')
# List existing preview images
upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{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"detailers/{slug}/{f}" for f in files]
return render_template('detailers/detail.html', detailer=detailer, characters=characters,
actions=actions, preferences=preferences, preview_image=preview_image,
selected_character=selected_character, selected_action=selected_action,
extra_positive=extra_positive, extra_negative=extra_negative,
existing_previews=existing_previews)
@app.route('/detailer/<path:slug>/edit', methods=['GET', 'POST'])
def edit_detailer(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
loras = get_available_loras('detailers')
if request.method == 'POST':
try:
# 1. Update basic fields
detailer.name = request.form.get('detailer_name')
# 2. Rebuild the data dictionary
new_data = detailer.data.copy()
new_data['detailer_name'] = detailer.name
# Update prompt (stored as a plain string)
new_data['prompt'] = request.form.get('detailer_prompt', '')
# 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 = 1.0
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 t in tags_raw.split(',') if t.strip()]
detailer.data = new_data
flag_modified(detailer, "data")
# 3. Write back to JSON file
detailer_file = detailer.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', detailer.detailer_id)}.json"
file_path = os.path.join(app.config['DETAILERS_DIR'], detailer_file)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
db.session.commit()
flash('Detailer updated successfully!')
return redirect(url_for('detailer_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
flash(f"Error saving changes: {str(e)}")
return render_template('detailers/edit.html', detailer=detailer, loras=loras)
@app.route('/detailer/<path:slug>/upload', methods=['POST'])
def upload_detailer_image(slug):
detailer = Detailer.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 detailer subfolder
detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}")
os.makedirs(detailer_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(detailer_folder, filename)
file.save(file_path)
# Store relative path in DB
detailer.image_path = f"detailers/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/generate', methods=['POST'])
def generate_detailer_image(slug):
detailer_obj = Detailer.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 = _resolve_character(character_slug)
if character_slug == '__random__' and character:
character_slug = character.slug
# Get selected action (if any)
action_slug = request.form.get('action_slug', '')
action_obj = Action.query.filter_by(slug=action_slug).first() if action_slug else None
# Get additional prompts
extra_positive = request.form.get('extra_positive', '').strip()
extra_negative = request.form.get('extra_negative', '').strip()
# Save preferences
session[f'char_detailer_{slug}'] = character_slug
session[f'action_detailer_{slug}'] = action_slug
session[f'extra_pos_detailer_{slug}'] = extra_positive
session[f'extra_neg_detailer_{slug}'] = extra_negative
session[f'prefs_detailer_{slug}'] = selected_fields
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 using helper
workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative, fixed_seed=fixed_seed)
char_label = character.name if character else 'no character'
label = f"Detailer: {detailer_obj.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('detailer_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('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/save_defaults', methods=['POST'])
def save_detailer_defaults(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
detailer.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this detailer!')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_detailer_cover_from_preview(slug):
detailer = Detailer.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)):
detailer.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/save_json', methods=['POST'])
def save_detailer_json(slug):
detailer = Detailer.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
detailer.data = new_data
flag_modified(detailer, 'data')
db.session.commit()
if detailer.filename:
file_path = os.path.join(app.config['DETAILERS_DIR'], detailer.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/detailers/bulk_create', methods=['POST'])
def bulk_create_detailers_from_loras():
_s = Settings.query.first()
detailers_lora_dir = ((_s.lora_dir_detailers if _s else None) or '/ImageModels/lora/Illustrious/Detailers').rstrip('/')
_lora_subfolder = os.path.basename(detailers_lora_dir)
if not os.path.exists(detailers_lora_dir):
flash('Detailers LoRA directory not found.', 'error')
return redirect(url_for('detailers_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('detailer_system.txt')
if not system_prompt:
flash('Detailer system prompt file not found.', 'error')
return redirect(url_for('detailers_index'))
for filename in os.listdir(detailers_lora_dir):
if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0]
detailer_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
detailer_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{detailer_id}.json"
json_path = os.path.join(app.config['DETAILERS_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(detailers_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'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', 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 detailer: {detailer_name}")
prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
detailer_data = json.loads(clean_json)
detailer_data['detailer_id'] = detailer_id
detailer_data['detailer_name'] = detailer_name
if 'lora' not in detailer_data: detailer_data['lora'] = {}
detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not detailer_data['lora'].get('lora_triggers'):
detailer_data['lora']['lora_triggers'] = name_base
if detailer_data['lora'].get('lora_weight') is None:
detailer_data['lora']['lora_weight'] = 1.0
if detailer_data['lora'].get('lora_weight_min') is None:
detailer_data['lora']['lora_weight_min'] = 0.7
if detailer_data['lora'].get('lora_weight_max') is None:
detailer_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f:
json.dump(detailer_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
except Exception as e:
print(f"Error creating detailer for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
sync_detailers()
msg = f'Successfully processed detailers: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No new detailers created or overwritten. {skipped_count} existing detailers found.')
return redirect(url_for('detailers_index'))
@app.route('/detailer/create', methods=['GET', 'POST'])
def create_detailer():
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug)
if not safe_slug:
safe_slug = 'detailer'
base_slug = safe_slug
counter = 1
while os.path.exists(os.path.join(app.config['DETAILERS_DIR'], f"{safe_slug}.json")):
safe_slug = f"{base_slug}_{counter}"
counter += 1
detailer_data = {
"detailer_id": safe_slug,
"detailer_name": name,
"prompt": "",
"lora": {
"lora_name": "",
"lora_weight": 1.0,
"lora_triggers": ""
}
}
try:
file_path = os.path.join(app.config['DETAILERS_DIR'], f"{safe_slug}.json")
with open(file_path, 'w') as f:
json.dump(detailer_data, f, indent=2)
new_detailer = Detailer(
detailer_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json",
name=name, data=detailer_data
)
db.session.add(new_detailer)
db.session.commit()
flash('Detailer created successfully!')
return redirect(url_for('detailer_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create detailer: {e}")
return redirect(request.url)
return render_template('detailers/create.html')

332
routes/gallery.py Normal file
View File

@@ -0,0 +1,332 @@
import json
import os
import logging
from flask import render_template, request, current_app
from models import (
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint,
)
logger = logging.getLogger('gaze')
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints']
_MODEL_MAP = {
'characters': Character,
'actions': Action,
'outfits': Outfit,
'scenes': Scene,
'styles': Style,
'detailers': Detailer,
'checkpoints': Checkpoint,
}
def register_routes(app):
def _scan_gallery_images(category_filter='all', slug_filter=''):
"""Return sorted list of image dicts from the uploads directory."""
upload_folder = app.config['UPLOAD_FOLDER']
images = []
cats = GALLERY_CATEGORIES if category_filter == 'all' else [category_filter]
for cat in cats:
cat_folder = os.path.join(upload_folder, cat)
if not os.path.isdir(cat_folder):
continue
try:
slugs = os.listdir(cat_folder)
except OSError:
continue
for item_slug in slugs:
if slug_filter and slug_filter != item_slug:
continue
item_folder = os.path.join(cat_folder, item_slug)
if not os.path.isdir(item_folder):
continue
try:
files = os.listdir(item_folder)
except OSError:
continue
for filename in files:
if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
continue
try:
ts = int(filename.replace('gen_', '').rsplit('.', 1)[0])
except ValueError:
ts = 0
images.append({
'path': f"{cat}/{item_slug}/{filename}",
'category': cat,
'slug': item_slug,
'filename': filename,
'timestamp': ts,
})
images.sort(key=lambda x: x['timestamp'], reverse=True)
return images
def _enrich_with_names(images):
"""Add item_name field to each image dict, querying DB once per category."""
by_cat = {}
for img in images:
by_cat.setdefault(img['category'], set()).add(img['slug'])
name_map = {}
for cat, slugs in by_cat.items():
Model = _MODEL_MAP.get(cat)
if not Model:
continue
items = Model.query.filter(Model.slug.in_(slugs)).with_entities(Model.slug, Model.name).all()
for slug, name in items:
name_map[(cat, slug)] = name
for img in images:
img['item_name'] = name_map.get((img['category'], img['slug']), img['slug'])
return images
def _parse_comfy_png_metadata(image_path):
"""Read ComfyUI generation metadata from a PNG's tEXt 'prompt' chunk.
Returns a dict with keys: positive, negative, checkpoint, loras,
seed, steps, cfg, sampler, scheduler. Any missing field is None/[].
"""
from PIL import Image as PilImage
result = {
'positive': None,
'negative': None,
'checkpoint': None,
'loras': [], # list of {name, strength}
'seed': None,
'steps': None,
'cfg': None,
'sampler': None,
'scheduler': None,
}
try:
with PilImage.open(image_path) as im:
raw = im.info.get('prompt')
if not raw:
return result
nodes = json.loads(raw)
except Exception:
return result
for node in nodes.values():
ct = node.get('class_type', '')
inp = node.get('inputs', {})
if ct == 'KSampler':
result['seed'] = inp.get('seed')
result['steps'] = inp.get('steps')
result['cfg'] = inp.get('cfg')
result['sampler'] = inp.get('sampler_name')
result['scheduler'] = inp.get('scheduler')
elif ct == 'CheckpointLoaderSimple':
result['checkpoint'] = inp.get('ckpt_name')
elif ct == 'CLIPTextEncode':
# Identify positive vs negative by which KSampler input they connect to.
# Simpler heuristic: node "6" = positive, node "7" = negative (our fixed workflow).
# But to be robust, we check both via node graph references where possible.
# Fallback: first CLIPTextEncode = positive, second = negative.
text = inp.get('text', '')
if result['positive'] is None:
result['positive'] = text
elif result['negative'] is None:
result['negative'] = text
elif ct == 'LoraLoader':
name = inp.get('lora_name', '')
if name:
result['loras'].append({
'name': name,
'strength': inp.get('strength_model', 1.0),
})
# Re-parse with fixed node IDs from the known workflow (more reliable)
try:
if '6' in nodes:
result['positive'] = nodes['6']['inputs'].get('text', result['positive'])
if '7' in nodes:
result['negative'] = nodes['7']['inputs'].get('text', result['negative'])
except Exception:
pass
return result
@app.route('/gallery')
def gallery():
category = request.args.get('category', 'all')
slug = request.args.get('slug', '')
sort = request.args.get('sort', 'newest')
page = max(1, int(request.args.get('page', 1)))
per_page = int(request.args.get('per_page', 48))
per_page = per_page if per_page in (24, 48, 96) else 48
images = _scan_gallery_images(category, slug)
if sort == 'oldest':
images.reverse()
elif sort == 'random':
import random
random.shuffle(images)
total = len(images)
total_pages = max(1, (total + per_page - 1) // per_page)
page = min(page, total_pages)
page_images = images[(page - 1) * per_page: page * per_page]
_enrich_with_names(page_images)
# Enrich with metadata for Info view
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
for img in page_images:
abs_img = os.path.join(upload_folder, img['path'])
if os.path.isfile(abs_img) and abs_img.lower().endswith('.png'):
img['meta'] = _parse_comfy_png_metadata(abs_img)
else:
img['meta'] = {}
slug_options = []
if category != 'all':
Model = _MODEL_MAP.get(category)
if Model:
slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()]
return render_template(
'gallery.html',
images=page_images,
page=page,
per_page=per_page,
total=total,
total_pages=total_pages,
category=category,
slug=slug,
sort=sort,
categories=GALLERY_CATEGORIES,
slug_options=slug_options,
)
@app.route('/gallery/prompt-data')
def gallery_prompt_data():
"""Return generation metadata for a specific image by reading its PNG tEXt chunk."""
img_path = request.args.get('path', '')
if not img_path:
return {'error': 'path parameter required'}, 400
# Validate path stays within uploads folder
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
if not abs_img.startswith(upload_folder + os.sep):
return {'error': 'Invalid path'}, 400
if not os.path.isfile(abs_img):
return {'error': 'File not found'}, 404
meta = _parse_comfy_png_metadata(abs_img)
meta['path'] = img_path
return meta
@app.route('/gallery/delete', methods=['POST'])
def gallery_delete():
"""Delete a generated image from the gallery. Only the image file is removed."""
data = request.get_json(silent=True) or {}
img_path = data.get('path', '')
if not img_path:
return {'error': 'path required'}, 400
if len(img_path.split('/')) != 3:
return {'error': 'invalid path format'}, 400
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
if not abs_img.startswith(upload_folder + os.sep):
return {'error': 'Invalid path'}, 400
if os.path.isfile(abs_img):
os.remove(abs_img)
return {'status': 'ok'}
@app.route('/resource/<category>/<slug>/delete', methods=['POST'])
def resource_delete(category, slug):
"""Delete a resource item from a category gallery.
soft: removes JSON data file + DB record; LoRA/checkpoint file kept on disk.
hard: removes JSON data file + LoRA/checkpoint safetensors + DB record.
"""
_RESOURCE_MODEL_MAP = {
'looks': Look,
'styles': Style,
'actions': Action,
'outfits': Outfit,
'scenes': Scene,
'detailers': Detailer,
'checkpoints': Checkpoint,
}
_RESOURCE_DATA_DIRS = {
'looks': app.config['LOOKS_DIR'],
'styles': app.config['STYLES_DIR'],
'actions': app.config['ACTIONS_DIR'],
'outfits': app.config['CLOTHING_DIR'],
'scenes': app.config['SCENES_DIR'],
'detailers': app.config['DETAILERS_DIR'],
'checkpoints': app.config['CHECKPOINTS_DIR'],
}
_LORA_BASE = '/ImageModels/lora/'
if category not in _RESOURCE_MODEL_MAP:
return {'error': 'unknown category'}, 400
req = request.get_json(silent=True) or {}
mode = req.get('mode', 'soft')
data_dir = _RESOURCE_DATA_DIRS[category]
json_path = os.path.join(data_dir, f'{slug}.json')
deleted = []
asset_abs = None
# Resolve asset path before deleting JSON (hard only)
if mode == 'hard' and os.path.isfile(json_path):
try:
with open(json_path) as f:
item_data = json.load(f)
if category == 'checkpoints':
ckpt_rel = item_data.get('checkpoint_path', '')
if ckpt_rel.startswith('Illustrious/'):
asset_abs = os.path.join(app.config['ILLUSTRIOUS_MODELS_DIR'],
ckpt_rel[len('Illustrious/'):])
elif ckpt_rel.startswith('Noob/'):
asset_abs = os.path.join(app.config['NOOB_MODELS_DIR'],
ckpt_rel[len('Noob/'):])
else:
lora_name = item_data.get('lora', {}).get('lora_name', '')
if lora_name:
asset_abs = os.path.join(_LORA_BASE, lora_name)
except Exception:
pass
# Delete JSON
if os.path.isfile(json_path):
os.remove(json_path)
deleted.append('json')
# Delete LoRA/checkpoint file (hard only)
if mode == 'hard' and asset_abs and os.path.isfile(asset_abs):
os.remove(asset_abs)
deleted.append('lora' if category != 'checkpoints' else 'checkpoint')
# Remove DB record
Model = _RESOURCE_MODEL_MAP[category]
rec = Model.query.filter_by(slug=slug).first()
if rec:
db.session.delete(rec)
db.session.commit()
deleted.append('db')
return {'status': 'ok', 'deleted': deleted}

139
routes/generator.py Normal file
View File

@@ -0,0 +1,139 @@
import json
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint
from services.prompts import build_prompt, build_extras_prompt
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize
from services.file_io import get_available_checkpoints
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/generator', methods=['GET', 'POST'])
def generator():
characters = Character.query.order_by(Character.name).all()
checkpoints = get_available_checkpoints()
actions = Action.query.order_by(Action.name).all()
outfits = Outfit.query.order_by(Outfit.name).all()
scenes = Scene.query.order_by(Scene.name).all()
styles = Style.query.order_by(Style.name).all()
detailers = Detailer.query.order_by(Detailer.name).all()
if not checkpoints:
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
if request.method == 'POST':
char_slug = request.form.get('character')
checkpoint = request.form.get('checkpoint')
custom_positive = request.form.get('positive_prompt', '')
custom_negative = request.form.get('negative_prompt', '')
action_slugs = request.form.getlist('action_slugs')
outfit_slugs = request.form.getlist('outfit_slugs')
scene_slugs = request.form.getlist('scene_slugs')
style_slugs = request.form.getlist('style_slugs')
detailer_slugs = request.form.getlist('detailer_slugs')
override_prompt = request.form.get('override_prompt', '').strip()
width = request.form.get('width') or 1024
height = request.form.get('height') or 1024
character = Character.query.filter_by(slug=char_slug).first_or_404()
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
try:
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
# Build base prompts from character defaults
prompts = build_prompt(character.data, default_fields=character.default_fields)
if override_prompt:
prompts["main"] = override_prompt
else:
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
combined = prompts["main"]
if extras:
combined = f"{combined}, {extras}"
if custom_positive:
combined = f"{combined}, {custom_positive}"
prompts["main"] = combined
# Parse optional seed
seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None
# Prepare workflow - first selected item per category supplies its LoRA slot
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint).first() if checkpoint else None
workflow = _prepare_workflow(
workflow, character, prompts, checkpoint, custom_negative,
outfit=sel_outfits[0] if sel_outfits else None,
action=sel_actions[0] if sel_actions else None,
style=sel_styles[0] if sel_styles else None,
detailer=sel_detailers[0] if sel_detailers else None,
scene=sel_scenes[0] if sel_scenes else None,
width=width,
height=height,
checkpoint_data=ckpt_obj.data if ckpt_obj else None,
fixed_seed=fixed_seed,
)
print(f"Queueing generator prompt for {character.character_id}")
_finalize = _make_finalize('characters', character.slug)
label = f"Generator: {character.name}"
job = _enqueue_job(label, workflow, _finalize)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
flash("Generation queued.")
except Exception as e:
print(f"Generator error: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error: {str(e)}")
return render_template('generator.html', characters=characters, checkpoints=checkpoints,
actions=actions, outfits=outfits, scenes=scenes,
styles=styles, detailers=detailers)
@app.route('/generator/preview_prompt', methods=['POST'])
def generator_preview_prompt():
char_slug = request.form.get('character')
if not char_slug:
return {'error': 'No character selected'}, 400
character = Character.query.filter_by(slug=char_slug).first()
if not character:
return {'error': 'Character not found'}, 404
action_slugs = request.form.getlist('action_slugs')
outfit_slugs = request.form.getlist('outfit_slugs')
scene_slugs = request.form.getlist('scene_slugs')
style_slugs = request.form.getlist('style_slugs')
detailer_slugs = request.form.getlist('detailer_slugs')
custom_positive = request.form.get('positive_prompt', '')
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
prompts = build_prompt(character.data, default_fields=character.default_fields)
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
combined = prompts["main"]
if extras:
combined = f"{combined}, {extras}"
if custom_positive:
combined = f"{combined}, {custom_positive}"
return {'prompt': combined}

592
routes/looks.py Normal file
View File

@@ -0,0 +1,592 @@
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, Look, Action, Checkpoint, Settings, Outfit
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, _dedup_tags
from services.sync import sync_looks
from services.file_io import get_available_loras, _count_look_assignments
from services.llm import load_prompt, call_llm
from utils import allowed_file
logger = logging.getLogger('gaze')
def _ensure_look_lora_prefix(lora_name):
"""Ensure look LoRA paths have the correct 'Illustrious/Looks/' prefix."""
if not lora_name:
return lora_name
if not lora_name.startswith('Illustrious/Looks/'):
# Add the prefix if missing
if lora_name.startswith('Illustrious/'):
# Has Illustrious but wrong subfolder - replace
parts = lora_name.split('/', 1)
if len(parts) > 1:
lora_name = 'Illustrious/Looks/' + parts[1]
else:
lora_name = 'Illustrious/Looks/' + lora_name
else:
# No prefix at all - add it
lora_name = 'Illustrious/Looks/' + lora_name
return lora_name
def _fix_look_lora_data(lora_data):
"""Fix look LoRA data to ensure correct prefix."""
if not lora_data:
return lora_data
lora_name = lora_data.get('lora_name', '')
if lora_name:
lora_data = lora_data.copy() # Avoid mutating original
lora_data['lora_name'] = _ensure_look_lora_prefix(lora_name)
return lora_data
def register_routes(app):
@app.route('/looks')
def looks_index():
looks = Look.query.order_by(Look.name).all()
look_assignments = _count_look_assignments()
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments)
@app.route('/looks/rescan', methods=['POST'])
def rescan_looks():
sync_looks()
flash('Database synced with look files.')
return redirect(url_for('looks_index'))
@app.route('/look/<path:slug>')
def look_detail(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
characters = Character.query.order_by(Character.name).all()
# Pre-select the linked characters if set (supports multi-character assignment)
preferences = session.get(f'prefs_look_{slug}')
preview_image = session.get(f'preview_look_{slug}')
# Get linked character IDs (new character_ids JSON field)
linked_character_ids = look.character_ids or []
# Fallback to legacy character_id if character_ids is empty
if not linked_character_ids and look.character_id:
linked_character_ids = [look.character_id]
# Session-selected character for preview (single selection for generation)
selected_character = session.get(f'char_look_{slug}', linked_character_ids[0] if linked_character_ids else '')
# FIX: Add existing_previews scanning (matching other resource routes)
upload_folder = app.config['UPLOAD_FOLDER']
preview_dir = os.path.join(upload_folder, 'looks', slug)
existing_previews = []
if os.path.isdir(preview_dir):
for f in os.listdir(preview_dir):
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
existing_previews.append(f'looks/{slug}/{f}')
existing_previews.sort()
extra_positive = session.get(f'extra_pos_look_{slug}', '')
extra_negative = session.get(f'extra_neg_look_{slug}', '')
return render_template('looks/detail.html', look=look, characters=characters,
preferences=preferences, preview_image=preview_image,
selected_character=selected_character,
linked_character_ids=linked_character_ids,
existing_previews=existing_previews,
extra_positive=extra_positive, extra_negative=extra_negative)
@app.route('/look/<path:slug>/edit', methods=['GET', 'POST'])
def edit_look(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
characters = Character.query.order_by(Character.name).all()
loras = get_available_loras('characters')
if request.method == 'POST':
look.name = request.form.get('look_name', look.name)
# Handle multiple character IDs from checkboxes
character_ids = request.form.getlist('character_ids')
look.character_ids = character_ids if character_ids else []
# Also update legacy character_id field for backward compatibility
if character_ids:
look.character_id = character_ids[0]
else:
look.character_id = None
new_data = look.data.copy()
new_data['look_name'] = look.name
new_data['character_id'] = look.character_id
new_data['positive'] = request.form.get('positive', '')
new_data['negative'] = request.form.get('negative', '')
lora_name = request.form.get('lora_lora_name', '')
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
lora_triggers = request.form.get('lora_lora_triggers', '')
new_data['lora'] = {'lora_name': lora_name, 'lora_weight': lora_weight, 'lora_triggers': lora_triggers}
for bound in ['lora_weight_min', 'lora_weight_max']:
val_str = request.form.get(f'lora_{bound}', '').strip()
if val_str:
try:
new_data['lora'][bound] = float(val_str)
except ValueError:
pass
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
look.data = new_data
flag_modified(look, 'data')
db.session.commit()
if look.filename:
file_path = os.path.join(app.config['LOOKS_DIR'], look.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
flash(f'Look "{look.name}" updated!')
return redirect(url_for('look_detail', slug=look.slug))
return render_template('looks/edit.html', look=look, characters=characters, loras=loras)
@app.route('/look/<path:slug>/upload', methods=['POST'])
def upload_look_image(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file selected')
return redirect(url_for('look_detail', slug=slug))
file = request.files['image']
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f'looks/{slug}')
os.makedirs(look_folder, exist_ok=True)
file_path = os.path.join(look_folder, filename)
file.save(file_path)
look.image_path = f'looks/{slug}/{filename}'
db.session.commit()
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/generate', methods=['POST'])
def generate_look_image(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
try:
action = request.form.get('action', 'preview')
selected_fields = request.form.getlist('include_field')
character_slug = request.form.get('character_slug', '')
character = None
# Only load a character when the user explicitly selects one
character = _resolve_character(character_slug)
if character_slug == '__random__' and character:
character_slug = character.slug
elif character_slug and not character:
# fallback: try matching by character_id
character = Character.query.filter_by(character_id=character_slug).first()
# No fallback to look.character_id — looks are self-contained
# Get additional prompts
extra_positive = request.form.get('extra_positive', '').strip()
extra_negative = request.form.get('extra_negative', '').strip()
session[f'prefs_look_{slug}'] = selected_fields
session[f'char_look_{slug}'] = character_slug
session[f'extra_pos_look_{slug}'] = extra_positive
session[f'extra_neg_look_{slug}'] = extra_negative
session.modified = True
lora_triggers = look.data.get('lora', {}).get('lora_triggers', '')
look_positive = look.data.get('positive', '')
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
if character:
# Merge character identity with look LoRA and positive prompt
combined_data = {
'character_id': character.character_id,
'identity': character.data.get('identity', {}),
'defaults': character.data.get('defaults', {}),
'wardrobe': character.data.get('wardrobe', {}).get(character.active_outfit or 'default',
character.data.get('wardrobe', {}).get('default', {})),
'styles': character.data.get('styles', {}),
'lora': _fix_look_lora_data(look.data.get('lora', {})),
'tags': look.data.get('tags', [])
}
_ensure_character_fields(character, selected_fields,
include_wardrobe=False, include_defaults=True)
prompts = build_prompt(combined_data, selected_fields, character.default_fields)
# Append look-specific triggers and positive
extra = ', '.join(filter(None, [lora_triggers, look_positive]))
if extra:
prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra}" if prompts['main'] else extra)
primary_color = character.data.get('styles', {}).get('primary_color', '')
bg = f"{primary_color} simple background" if primary_color else "simple background"
else:
# Look is self-contained: build prompt from its own positive and triggers only
main = _dedup_tags(', '.join(filter(None, ['(solo:1.2)', lora_triggers, look_positive])))
prompts = {'main': main, 'face': '', 'hand': ''}
bg = "simple background"
prompts['main'] = _dedup_tags(f"{prompts['main']}, {bg}" if prompts['main'] else bg)
if extra_positive:
prompts["main"] = f"{prompts['main']}, {extra_positive}"
# Parse optional seed
seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None
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, look=look, fixed_seed=fixed_seed)
char_label = character.name if character else 'no character'
label = f"Look: {look.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('looks', slug, Look, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('look_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('look_detail', slug=slug))
@app.route('/look/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_look_cover_from_preview(slug):
look = Look.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)):
look.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/save_defaults', methods=['POST'])
def save_look_defaults(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
look.default_fields = request.form.getlist('include_field')
db.session.commit()
flash('Default prompt selection saved!')
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/generate_character', methods=['POST'])
def generate_character_from_look(slug):
"""Generate a character JSON using a look as the base."""
look = Look.query.filter_by(slug=slug).first_or_404()
# Get or validate inputs
character_name = request.form.get('character_name', look.name)
use_llm = request.form.get('use_llm') == 'on'
# Auto-generate slug
character_slug = re.sub(r'[^a-zA-Z0-9]+', '_', character_name.lower()).strip('_')
character_slug = re.sub(r'[^a-zA-Z0-9_]', '', character_slug)
# Find available filename
base_slug = character_slug
counter = 1
while os.path.exists(os.path.join(app.config['CHARACTERS_DIR'], f"{character_slug}.json")):
character_slug = f"{base_slug}_{counter}"
counter += 1
if use_llm:
# Use LLM to generate character from look context
system_prompt = load_prompt('character_system.txt')
if not system_prompt:
flash('Character system prompt file not found.', 'error')
return redirect(url_for('look_detail', slug=slug))
prompt = f"""Generate a character based on this look description:
Look Name: {look.name}
Positive Prompt: {look.data.get('positive', '')}
Negative Prompt: {look.data.get('negative', '')}
Tags: {', '.join(look.data.get('tags', []))}
LoRA Triggers: {look.data.get('lora', {}).get('lora_triggers', '')}
Create a complete character JSON with identity, styles, and appropriate wardrobe fields.
The character should match the visual style described in the look.
Character Name: {character_name}
Character ID: {character_slug}"""
try:
llm_response = call_llm(prompt, system_prompt)
# Clean response (remove markdown if present)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
character_data = json.loads(clean_json)
# Enforce IDs
character_data['character_id'] = character_slug
character_data['character_name'] = character_name
# Ensure the character inherits the look's LoRA with correct path
lora_data = _fix_look_lora_data(look.data.get('lora', {}).copy())
character_data['lora'] = lora_data
except Exception as e:
logger.exception(f"LLM character generation error: {e}")
flash(f'Failed to generate character with AI: {e}', 'error')
return redirect(url_for('look_detail', slug=slug))
else:
# Create minimal character template
lora_data = _fix_look_lora_data(look.data.get('lora', {}).copy())
character_data = {
"character_id": character_slug,
"character_name": character_name,
"identity": {
"base_specs": lora_data.get('lora_triggers', ''),
"hair": "",
"eyes": "",
"hands": "",
"arms": "",
"torso": "",
"pelvis": "",
"legs": "",
"feet": "",
"extra": ""
},
"defaults": {
"expression": "",
"pose": "",
"scene": ""
},
"wardrobe": {
"full_body": "",
"headwear": "",
"top": "",
"bottom": "",
"legwear": "",
"footwear": "",
"hands": "",
"accessories": ""
},
"styles": {
"aesthetic": "",
"primary_color": "",
"secondary_color": "",
"tertiary_color": ""
},
"lora": lora_data,
"tags": look.data.get('tags', [])
}
# Save character JSON
char_path = os.path.join(app.config['CHARACTERS_DIR'], f"{character_slug}.json")
try:
with open(char_path, 'w') as f:
json.dump(character_data, f, indent=2)
except Exception as e:
flash(f'Failed to save character file: {e}', 'error')
return redirect(url_for('look_detail', slug=slug))
# Create DB entry
character = Character(
character_id=character_slug,
slug=character_slug,
name=character_name,
data=character_data
)
db.session.add(character)
db.session.commit()
# Link the look to this character
look.character_id = character_slug
db.session.commit()
flash(f'Character "{character_name}" created from look!', 'success')
return redirect(url_for('detail', slug=character_slug))
@app.route('/look/<path:slug>/save_json', methods=['POST'])
def save_look_json(slug):
look = Look.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
look.data = new_data
look.character_id = new_data.get('character_id', look.character_id)
flag_modified(look, 'data')
db.session.commit()
if look.filename:
file_path = os.path.join(app.config['LOOKS_DIR'], look.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/look/create', methods=['GET', 'POST'])
def create_look():
characters = Character.query.order_by(Character.name).all()
loras = get_available_loras('characters')
if request.method == 'POST':
name = request.form.get('name', '').strip()
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
filename = f'{look_id}.json'
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
character_id = request.form.get('character_id', '') or None
lora_name = request.form.get('lora_lora_name', '')
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
lora_triggers = request.form.get('lora_lora_triggers', '')
positive = request.form.get('positive', '')
negative = request.form.get('negative', '')
tags = [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()]
data = {
'look_id': look_id,
'look_name': name,
'character_id': character_id,
'positive': positive,
'negative': negative,
'lora': {'lora_name': lora_name, 'lora_weight': lora_weight, 'lora_triggers': lora_triggers},
'tags': tags
}
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
with open(file_path, 'w') as f:
json.dump(data, f, indent=2)
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
character_id=character_id, data=data)
db.session.add(new_look)
db.session.commit()
flash(f'Look "{name}" created!')
return redirect(url_for('look_detail', slug=slug))
return render_template('looks/create.html', characters=characters, loras=loras)
@app.route('/get_missing_looks')
def get_missing_looks():
missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.name).all()
return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]}
@app.route('/clear_all_look_covers', methods=['POST'])
def clear_all_look_covers():
looks = Look.query.all()
for look in looks:
look.image_path = None
db.session.commit()
return {'success': True}
@app.route('/looks/bulk_create', methods=['POST'])
def bulk_create_looks_from_loras():
_s = Settings.query.first()
lora_dir = ((_s.lora_dir_characters if _s else None) or app.config['LORA_DIR']).rstrip('/')
_lora_subfolder = os.path.basename(lora_dir)
if not os.path.exists(lora_dir):
flash('Looks LoRA directory not found.', 'error')
return redirect(url_for('looks_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('look_system.txt')
if not system_prompt:
flash('Look system prompt file not found.', 'error')
return redirect(url_for('looks_index'))
for filename in os.listdir(lora_dir):
if not filename.endswith('.safetensors'):
continue
name_base = filename.rsplit('.', 1)[0]
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{look_id}.json"
json_path = os.path.join(app.config['LOOKS_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(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'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', 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 look: {look_name}")
prompt = f"Create a look profile for a character appearance 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()
look_data = json.loads(clean_json)
look_data['look_id'] = look_id
look_data['look_name'] = look_name
if 'lora' not in look_data:
look_data['lora'] = {}
look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not look_data['lora'].get('lora_triggers'):
look_data['lora']['lora_triggers'] = name_base
if look_data['lora'].get('lora_weight') is None:
look_data['lora']['lora_weight'] = 0.8
if look_data['lora'].get('lora_weight_min') is None:
look_data['lora']['lora_weight_min'] = 0.7
if look_data['lora'].get('lora_weight_max') is None:
look_data['lora']['lora_weight_max'] = 1.0
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
with open(json_path, 'w') as f:
json.dump(look_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 look for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
sync_looks()
msg = f'Successfully processed looks: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No looks created or overwritten. {skipped_count} existing entries found.')
return redirect(url_for('looks_index'))

604
routes/outfits.py Normal file
View File

@@ -0,0 +1,604 @@
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'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', 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/<path:slug>')
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/<path:slug>/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/<path:slug>/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/<path:slug>/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.
for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
outfit_wardrobe = outfit.data.get('wardrobe', {})
for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']:
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/<path:slug>/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'] = {
"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'] = []
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": {
"full_body": "",
"headwear": "",
"top": "",
"bottom": "",
"legwear": "",
"footwear": "",
"hands": "",
"accessories": ""
},
"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/<path:slug>/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/<path:slug>/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/<path:slug>/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}

439
routes/presets.py Normal file
View File

@@ -0,0 +1,439 @@
import json
import os
import re
import logging
import random
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look, Settings
from sqlalchemy.orm.attributes import flag_modified
from services.prompts import build_prompt, _dedup_tags, _resolve_character, _ensure_character_fields, _append_background
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize
from services.sync import sync_presets, _resolve_preset_entity, _resolve_preset_fields, _PRESET_ENTITY_MAP
from services.llm import load_prompt, call_llm
from utils import allowed_file
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/presets')
def presets_index():
presets = Preset.query.order_by(Preset.filename).all()
return render_template('presets/index.html', presets=presets)
@app.route('/preset/<path:slug>')
def preset_detail(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_preset_{slug}')
extra_positive = session.get(f'extra_pos_preset_{slug}', '')
extra_negative = session.get(f'extra_neg_preset_{slug}', '')
return render_template('presets/detail.html', preset=preset, preview_path=preview_path,
extra_positive=extra_positive, extra_negative=extra_negative)
@app.route('/preset/<path:slug>/generate', methods=['POST'])
def generate_preset_image(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
try:
action = request.form.get('action', 'preview')
# Get additional prompts
extra_positive = request.form.get('extra_positive', '').strip()
extra_negative = request.form.get('extra_negative', '').strip()
session[f'extra_pos_preset_{slug}'] = extra_positive
session[f'extra_neg_preset_{slug}'] = extra_negative
session.modified = True
data = preset.data
# Resolve entities
char_cfg = data.get('character', {})
character = _resolve_preset_entity('character', char_cfg.get('character_id'))
if not character:
character = Character.query.order_by(db.func.random()).first()
outfit_cfg = data.get('outfit', {})
action_cfg = data.get('action', {})
style_cfg = data.get('style', {})
scene_cfg = data.get('scene', {})
detailer_cfg = data.get('detailer', {})
look_cfg = data.get('look', {})
ckpt_cfg = data.get('checkpoint', {})
outfit = _resolve_preset_entity('outfit', outfit_cfg.get('outfit_id'))
action_obj = _resolve_preset_entity('action', action_cfg.get('action_id'))
style_obj = _resolve_preset_entity('style', style_cfg.get('style_id'))
scene_obj = _resolve_preset_entity('scene', scene_cfg.get('scene_id'))
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
# Checkpoint: preset override or session default
preset_ckpt = ckpt_cfg.get('checkpoint_path')
if preset_ckpt == 'random':
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first()
ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None
ckpt_data = ckpt_obj.data if ckpt_obj else None
elif preset_ckpt:
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first()
ckpt_path = preset_ckpt
ckpt_data = ckpt_obj.data if ckpt_obj else None
else:
ckpt_path, ckpt_data = _get_default_checkpoint()
# Resolve selected fields from preset toggles
selected_fields = _resolve_preset_fields(data)
# Build combined data for prompt building
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
if wardrobe_source is None:
wardrobe_source = character.get_active_wardrobe() if character else {}
combined_data = {
'character_id': character.character_id if character else 'unknown',
'identity': character.data.get('identity', {}) if character else {},
'defaults': character.data.get('defaults', {}) if character else {},
'wardrobe': wardrobe_source,
'styles': character.data.get('styles', {}) if character else {},
'lora': (look_obj.data.get('lora', {}) if look_obj
else (character.data.get('lora', {}) if character else {})),
'tags': (character.data.get('tags', []) if character else []) + data.get('tags', []),
}
# Build extras prompt from secondary resources
extras_parts = []
if action_obj:
action_fields = action_cfg.get('fields', {})
for key in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']:
val_cfg = action_fields.get(key, True)
if val_cfg == 'random':
val_cfg = random.choice([True, False])
if val_cfg:
val = action_obj.data.get('action', {}).get(key, '')
if val:
extras_parts.append(val)
if action_cfg.get('use_lora', True):
trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
extras_parts.extend(action_obj.data.get('tags', []))
if style_obj:
s = style_obj.data.get('style', {})
if s.get('artist_name'):
extras_parts.append(f"by {s['artist_name']}")
if s.get('artistic_style'):
extras_parts.append(s['artistic_style'])
if style_cfg.get('use_lora', True):
trg = style_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
if scene_obj:
scene_fields = scene_cfg.get('fields', {})
for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']:
val_cfg = scene_fields.get(key, True)
if val_cfg == 'random':
val_cfg = random.choice([True, False])
if val_cfg:
val = scene_obj.data.get('scene', {}).get(key, '')
if val:
extras_parts.append(val)
if scene_cfg.get('use_lora', True):
trg = scene_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
extras_parts.extend(scene_obj.data.get('tags', []))
if detailer_obj:
prompt_val = detailer_obj.data.get('prompt', '')
if isinstance(prompt_val, list):
extras_parts.extend(p for p in prompt_val if p)
elif prompt_val:
extras_parts.append(prompt_val)
if detailer_cfg.get('use_lora', True):
trg = detailer_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
prompts = build_prompt(combined_data, selected_fields, default_fields=None,
active_outfit=active_wardrobe)
if extras_parts:
extra_str = ', '.join(filter(None, extras_parts))
prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra_str}" if prompts['main'] else extra_str)
if extra_positive:
prompts["main"] = f"{prompts['main']}, {extra_positive}"
# Parse optional seed
seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None
workflow = _prepare_workflow(
workflow, character, prompts,
checkpoint=ckpt_path, checkpoint_data=ckpt_data,
custom_negative=extra_negative or None,
outfit=outfit if outfit_cfg.get('use_lora', True) else None,
action=action_obj if action_cfg.get('use_lora', True) else None,
style=style_obj if style_cfg.get('use_lora', True) else None,
scene=scene_obj if scene_cfg.get('use_lora', True) else None,
detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None,
look=look_obj,
fixed_seed=fixed_seed,
)
label = f"Preset: {preset.name} {action}"
job = _enqueue_job(label, workflow, _make_finalize('presets', slug, Preset, action))
session[f'preview_preset_{slug}'] = None
session.modified = True
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('preset_detail', slug=slug))
except Exception as e:
logger.exception("Generation error (preset %s): %s", slug, 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('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_preset_cover_from_preview(slug):
preset = Preset.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(current_app.config['UPLOAD_FOLDER'], preview_path)):
preset.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/upload', methods=['POST'])
def upload_preset_image(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file uploaded.')
return redirect(url_for('preset_detail', slug=slug))
file = request.files['image']
if file.filename == '':
flash('No file selected.')
return redirect(url_for('preset_detail', slug=slug))
filename = secure_filename(file.filename)
folder = os.path.join(current_app.config['UPLOAD_FOLDER'], f'presets/{slug}')
os.makedirs(folder, exist_ok=True)
file.save(os.path.join(folder, filename))
preset.image_path = f'presets/{slug}/{filename}'
db.session.commit()
flash('Image uploaded!')
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/edit', methods=['GET', 'POST'])
def edit_preset(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
if request.method == 'POST':
name = request.form.get('preset_name', preset.name)
preset.name = name
def _tog(val):
"""Convert form value ('true'/'false'/'random') to JSON toggle value."""
if val == 'random':
return 'random'
return val == 'true'
def _entity_id(val):
return val if val else None
char_id = _entity_id(request.form.get('char_character_id'))
new_data = {
'preset_id': preset.preset_id,
'preset_name': name,
'character': {
'character_id': char_id,
'use_lora': request.form.get('char_use_lora') == 'on',
'fields': {
'identity': {k: _tog(request.form.get(f'id_{k}', 'true'))
for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']},
'defaults': {k: _tog(request.form.get(f'def_{k}', 'false'))
for k in ['expression', 'pose', 'scene']},
'wardrobe': {
'outfit': request.form.get('wardrobe_outfit', 'default') or 'default',
'fields': {k: _tog(request.form.get(f'wd_{k}', 'true'))
for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']},
},
},
},
'outfit': {'outfit_id': _entity_id(request.form.get('outfit_id')),
'use_lora': request.form.get('outfit_use_lora') == 'on'},
'action': {'action_id': _entity_id(request.form.get('action_id')),
'use_lora': request.form.get('action_use_lora') == 'on',
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}},
'style': {'style_id': _entity_id(request.form.get('style_id')),
'use_lora': request.form.get('style_use_lora') == 'on'},
'scene': {'scene_id': _entity_id(request.form.get('scene_id')),
'use_lora': request.form.get('scene_use_lora') == 'on',
'fields': {k: _tog(request.form.get(f'scn_{k}', 'true'))
for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}},
'detailer': {'detailer_id': _entity_id(request.form.get('detailer_id')),
'use_lora': request.form.get('detailer_use_lora') == 'on'},
'look': {'look_id': _entity_id(request.form.get('look_id'))},
'checkpoint': {'checkpoint_path': _entity_id(request.form.get('checkpoint_path'))},
'tags': [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()],
}
preset.data = new_data
flag_modified(preset, "data")
db.session.commit()
if preset.filename:
file_path = os.path.join(current_app.config['PRESETS_DIR'], preset.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
flash('Preset saved!')
return redirect(url_for('preset_detail', slug=slug))
characters = Character.query.order_by(Character.name).all()
outfits = Outfit.query.order_by(Outfit.name).all()
actions = Action.query.order_by(Action.name).all()
styles = Style.query.order_by(Style.name).all()
scenes = Scene.query.order_by(Scene.name).all()
detailers = Detailer.query.order_by(Detailer.name).all()
looks = Look.query.order_by(Look.name).all()
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
return render_template('presets/edit.html', preset=preset,
characters=characters, outfits=outfits, actions=actions,
styles=styles, scenes=scenes, detailers=detailers,
looks=looks, checkpoints=checkpoints)
@app.route('/preset/<path:slug>/save_json', methods=['POST'])
def save_preset_json(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
preset.data = new_data
preset.name = new_data.get('preset_name', preset.name)
flag_modified(preset, "data")
db.session.commit()
if preset.filename:
file_path = os.path.join(current_app.config['PRESETS_DIR'], preset.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}, 400
@app.route('/preset/<path:slug>/clone', methods=['POST'])
def clone_preset(slug):
original = Preset.query.filter_by(slug=slug).first_or_404()
new_data = dict(original.data)
base_id = f"{original.preset_id}_copy"
new_id = base_id
counter = 1
while Preset.query.filter_by(preset_id=new_id).first():
new_id = f"{base_id}_{counter}"
counter += 1
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_data['preset_id'] = new_id
new_data['preset_name'] = f"{original.name} (Copy)"
new_filename = f"{new_id}.json"
os.makedirs(current_app.config['PRESETS_DIR'], exist_ok=True)
with open(os.path.join(current_app.config['PRESETS_DIR'], new_filename), 'w') as f:
json.dump(new_data, f, indent=2)
new_preset = Preset(preset_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['preset_name'], data=new_data)
db.session.add(new_preset)
db.session.commit()
flash(f"Cloned as '{new_data['preset_name']}'")
return redirect(url_for('preset_detail', slug=new_slug))
@app.route('/presets/rescan', methods=['POST'])
def rescan_presets():
sync_presets()
flash('Preset library synced.')
return redirect(url_for('presets_index'))
@app.route('/preset/create', methods=['GET', 'POST'])
def create_preset():
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
use_llm = request.form.get('use_llm') == 'on'
safe_id = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') or 'preset'
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
base_id = safe_id
counter = 1
while os.path.exists(os.path.join(current_app.config['PRESETS_DIR'], f"{safe_id}.json")):
safe_id = f"{base_id}_{counter}"
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
counter += 1
if use_llm and description:
system_prompt = load_prompt('preset_system.txt')
if not system_prompt:
flash('Preset system prompt file not found.', 'error')
return redirect(request.url)
try:
llm_response = call_llm(
f"Create a preset profile named '{name}' based on this description: {description}",
system_prompt
)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
preset_data = json.loads(clean_json)
except Exception as e:
logger.exception("LLM error creating preset: %s", e)
flash(f"AI generation failed: {e}", 'error')
return redirect(request.url)
else:
preset_data = {
'character': {'character_id': 'random', 'use_lora': True,
'fields': {
'identity': {k: True for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']},
'defaults': {k: False for k in ['expression', 'pose', 'scene']},
'wardrobe': {'outfit': 'default',
'fields': {k: True for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}},
}},
'outfit': {'outfit_id': None, 'use_lora': True},
'action': {'action_id': None, 'use_lora': True,
'fields': {k: True for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}},
'style': {'style_id': None, 'use_lora': True},
'scene': {'scene_id': None, 'use_lora': True,
'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}},
'detailer': {'detailer_id': None, 'use_lora': True},
'look': {'look_id': None},
'checkpoint': {'checkpoint_path': None},
'tags': [],
}
preset_data['preset_id'] = safe_id
preset_data['preset_name'] = name
os.makedirs(current_app.config['PRESETS_DIR'], exist_ok=True)
file_path = os.path.join(current_app.config['PRESETS_DIR'], f"{safe_id}.json")
with open(file_path, 'w') as f:
json.dump(preset_data, f, indent=2)
new_preset = Preset(preset_id=safe_id, slug=safe_slug,
filename=f"{safe_id}.json", name=name, data=preset_data)
db.session.add(new_preset)
db.session.commit()
flash(f"Preset '{name}' created!")
return redirect(url_for('edit_preset', slug=safe_slug))
return render_template('presets/create.html')
@app.route('/get_missing_presets')
def get_missing_presets():
missing = Preset.query.filter((Preset.image_path == None) | (Preset.image_path == '')).order_by(Preset.filename).all()
return {'missing': [{'slug': p.slug, 'name': p.name} for p in missing]}

98
routes/queue_api.py Normal file
View File

@@ -0,0 +1,98 @@
import logging
from services.job_queue import (
_job_queue_lock, _job_queue, _job_history, _queue_worker_event,
)
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/api/queue')
def api_queue_list():
"""Return the current queue as JSON."""
with _job_queue_lock:
jobs = [
{
'id': j['id'],
'label': j['label'],
'status': j['status'],
'error': j['error'],
'created_at': j['created_at'],
}
for j in _job_queue
]
return {'jobs': jobs, 'count': len(jobs)}
@app.route('/api/queue/count')
def api_queue_count():
"""Return just the count of active (non-done, non-failed) jobs."""
with _job_queue_lock:
count = sum(1 for j in _job_queue if j['status'] in ('pending', 'processing', 'paused'))
return {'count': count}
@app.route('/api/queue/<job_id>/remove', methods=['POST'])
def api_queue_remove(job_id):
"""Remove a pending or paused job from the queue."""
with _job_queue_lock:
job = _job_history.get(job_id)
if not job:
return {'error': 'Job not found'}, 404
if job['status'] == 'processing':
return {'error': 'Cannot remove a job that is currently processing'}, 400
try:
_job_queue.remove(job)
except ValueError:
pass # Already not in queue
job['status'] = 'removed'
return {'status': 'ok'}
@app.route('/api/queue/<job_id>/pause', methods=['POST'])
def api_queue_pause(job_id):
"""Toggle pause/resume on a pending job."""
with _job_queue_lock:
job = _job_history.get(job_id)
if not job:
return {'error': 'Job not found'}, 404
if job['status'] == 'pending':
job['status'] = 'paused'
elif job['status'] == 'paused':
job['status'] = 'pending'
_queue_worker_event.set()
else:
return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400
return {'status': 'ok', 'new_status': job['status']}
@app.route('/api/queue/clear', methods=['POST'])
def api_queue_clear():
"""Clear all pending jobs from the queue (allows current processing job to finish)."""
removed_count = 0
with _job_queue_lock:
pending_jobs = [j for j in _job_queue if j['status'] == 'pending']
for job in pending_jobs:
try:
_job_queue.remove(job)
job['status'] = 'removed'
removed_count += 1
except ValueError:
pass
logger.info("Cleared %d pending jobs from queue", removed_count)
return {'status': 'ok', 'removed_count': removed_count}
@app.route('/api/queue/<job_id>/status')
def api_queue_job_status(job_id):
"""Return the status of a specific job."""
with _job_queue_lock:
job = _job_history.get(job_id)
if not job:
return {'error': 'Job not found'}, 404
resp = {
'id': job['id'],
'label': job['label'],
'status': job['status'],
'error': job['error'],
'comfy_prompt_id': job['comfy_prompt_id'],
}
if job.get('result'):
resp['result'] = job['result']
return resp

540
routes/scenes.py Normal file
View File

@@ -0,0 +1,540 @@
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, Scene, Outfit, Action, Style, 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_scenes
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_scenes')
def get_missing_scenes():
missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.name).all()
return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]}
@app.route('/clear_all_scene_covers', methods=['POST'])
def clear_all_scene_covers():
scenes = Scene.query.all()
for scene in scenes:
scene.image_path = None
db.session.commit()
return {'success': True}
@app.route('/scenes')
def scenes_index():
scenes = Scene.query.order_by(Scene.name).all()
return render_template('scenes/index.html', scenes=scenes)
@app.route('/scenes/rescan', methods=['POST'])
def rescan_scenes():
sync_scenes()
flash('Database synced with scene files.')
return redirect(url_for('scenes_index'))
@app.route('/scene/<path:slug>')
def scene_detail(slug):
scene = Scene.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_scene_{slug}')
preview_image = session.get(f'preview_scene_{slug}')
selected_character = session.get(f'char_scene_{slug}')
extra_positive = session.get(f'extra_pos_scene_{slug}', '')
extra_negative = session.get(f'extra_neg_scene_{slug}', '')
# List existing preview images
upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{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"scenes/{slug}/{f}" for f in files]
return render_template('scenes/detail.html', scene=scene, characters=characters,
preferences=preferences, preview_image=preview_image,
selected_character=selected_character, existing_previews=existing_previews,
extra_positive=extra_positive, extra_negative=extra_negative)
@app.route('/scene/<path:slug>/edit', methods=['GET', 'POST'])
def edit_scene(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
loras = get_available_loras('scenes')
if request.method == 'POST':
try:
# 1. Update basic fields
scene.name = request.form.get('scene_name')
# 2. Rebuild the data dictionary
new_data = scene.data.copy()
new_data['scene_name'] = scene.name
# Update scene section
if 'scene' in new_data:
for key in new_data['scene'].keys():
form_key = f"scene_{key}"
if form_key in request.form:
val = request.form.get(form_key)
# Handle list for furniture/colors if they were originally lists
if key in ['furniture', 'colors'] and isinstance(new_data['scene'][key], list):
val = [v.strip() for v in val.split(',') if v.strip()]
new_data['scene'][key] = val
# 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 = 1.0
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 t in tags_raw.split(',') if t.strip()]
scene.data = new_data
flag_modified(scene, "data")
# 3. Write back to JSON file
scene_file = scene.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', scene.scene_id)}.json"
file_path = os.path.join(app.config['SCENES_DIR'], scene_file)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
db.session.commit()
flash('Scene updated successfully!')
return redirect(url_for('scene_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
flash(f"Error saving changes: {str(e)}")
return render_template('scenes/edit.html', scene=scene, loras=loras)
@app.route('/scene/<path:slug>/upload', methods=['POST'])
def upload_scene_image(slug):
scene = Scene.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 scene subfolder
scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}")
os.makedirs(scene_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(scene_folder, filename)
file.save(file_path)
# Store relative path in DB
scene.image_path = f"scenes/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('scene_detail', slug=slug))
def _queue_scene_generation(scene_obj, character=None, selected_fields=None, client_id=None, fixed_seed=None, extra_positive=None, extra_negative=None):
if character:
combined_data = character.data.copy()
combined_data['character_id'] = character.character_id
# Update character's 'defaults' with scene details
scene_data = scene_obj.data.get('scene', {})
# Build scene tag string
scene_tags = []
for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']:
val = scene_data.get(key)
if val:
if isinstance(val, list):
scene_tags.extend(val)
else:
scene_tags.append(val)
combined_data['defaults']['scene'] = ", ".join(scene_tags)
# Merge scene lora triggers if present
scene_lora = scene_obj.data.get('lora', {})
if scene_lora.get('lora_triggers'):
if 'lora' not in combined_data: combined_data['lora'] = {}
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {scene_lora['lora_triggers']}"
# Merge character identity and wardrobe fields into selected_fields
if selected_fields:
_ensure_character_fields(character, selected_fields)
else:
# Auto-include essential character fields (minimal set for batch/default generation)
selected_fields = []
for key in ['base_specs', 'hair', 'eyes']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
selected_fields.append('special::name')
wardrobe = character.get_active_wardrobe()
for key in _WARDROBE_KEYS:
if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}')
selected_fields.extend(['defaults::scene', 'lora::lora_triggers'])
default_fields = scene_obj.default_fields
active_outfit = character.active_outfit
else:
# Scene only - no character
scene_data = scene_obj.data.get('scene', {})
scene_tags = []
for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']:
val = scene_data.get(key)
if val:
if isinstance(val, list): scene_tags.extend(val)
else: scene_tags.append(val)
combined_data = {
'character_id': scene_obj.scene_id,
'defaults': {
'scene': ", ".join(scene_tags)
},
'lora': scene_obj.data.get('lora', {}),
'tags': scene_obj.data.get('tags', [])
}
if not selected_fields:
selected_fields = ['defaults::scene', 'lora::lora_triggers']
default_fields = scene_obj.default_fields
active_outfit = 'default'
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
if extra_positive:
prompts["main"] = f"{prompts['main']}, {extra_positive}"
# For scene generation, we want to ensure Node 20 is handled in _prepare_workflow
ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, scene=scene_obj, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed)
return workflow
@app.route('/scene/<path:slug>/generate', methods=['POST'])
def generate_scene_image(slug):
scene_obj = Scene.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 = _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'char_scene_{slug}'] = character_slug
session[f'prefs_scene_{slug}'] = selected_fields
session[f'extra_pos_scene_{slug}'] = extra_positive
session[f'extra_neg_scene_{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 using helper
workflow = _queue_scene_generation(scene_obj, character, selected_fields, fixed_seed=fixed_seed, extra_positive=extra_positive, extra_negative=extra_negative)
char_label = character.name if character else 'no character'
label = f"Scene: {scene_obj.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('scenes', slug, Scene, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('scene_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('scene_detail', slug=slug))
@app.route('/scene/<path:slug>/save_defaults', methods=['POST'])
def save_scene_defaults(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
scene.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this scene!')
return redirect(url_for('scene_detail', slug=slug))
@app.route('/scene/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_scene_cover_from_preview(slug):
scene = Scene.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)):
scene.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('scene_detail', slug=slug))
@app.route('/scenes/bulk_create', methods=['POST'])
def bulk_create_scenes_from_loras():
_s = Settings.query.first()
backgrounds_lora_dir = ((_s.lora_dir_scenes if _s else None) or '/ImageModels/lora/Illustrious/Backgrounds').rstrip('/')
_lora_subfolder = os.path.basename(backgrounds_lora_dir)
if not os.path.exists(backgrounds_lora_dir):
flash('Backgrounds LoRA directory not found.', 'error')
return redirect(url_for('scenes_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('scene_system.txt')
if not system_prompt:
flash('Scene system prompt file not found.', 'error')
return redirect(url_for('scenes_index'))
for filename in os.listdir(backgrounds_lora_dir):
if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0]
scene_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
scene_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{scene_id}.json"
json_path = os.path.join(app.config['SCENES_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(backgrounds_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()
# Strip HTML tags but keep text content for LLM context
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', 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 scene: {scene_name}")
prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt)
# Clean response
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
scene_data = json.loads(clean_json)
# Enforce system values while preserving LLM-extracted metadata
scene_data['scene_id'] = scene_id
scene_data['scene_name'] = scene_name
if 'lora' not in scene_data: scene_data['lora'] = {}
scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not scene_data['lora'].get('lora_triggers'):
scene_data['lora']['lora_triggers'] = name_base
if scene_data['lora'].get('lora_weight') is None:
scene_data['lora']['lora_weight'] = 1.0
if scene_data['lora'].get('lora_weight_min') is None:
scene_data['lora']['lora_weight_min'] = 0.7
if scene_data['lora'].get('lora_weight_max') is None:
scene_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f:
json.dump(scene_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
except Exception as e:
print(f"Error creating scene for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
sync_scenes()
msg = f'Successfully processed scenes: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No scenes created or overwritten. {skipped_count} existing scenes found.')
return redirect(url_for('scenes_index'))
@app.route('/scene/create', methods=['GET', 'POST'])
def create_scene():
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug)
if not safe_slug:
safe_slug = 'scene'
base_slug = safe_slug
counter = 1
while os.path.exists(os.path.join(app.config['SCENES_DIR'], f"{safe_slug}.json")):
safe_slug = f"{base_slug}_{counter}"
counter += 1
scene_data = {
"scene_id": safe_slug,
"scene_name": name,
"scene": {
"background": "",
"foreground": "",
"furniture": [],
"colors": [],
"lighting": "",
"theme": ""
},
"lora": {
"lora_name": "",
"lora_weight": 1.0,
"lora_triggers": ""
}
}
try:
file_path = os.path.join(app.config['SCENES_DIR'], f"{safe_slug}.json")
with open(file_path, 'w') as f:
json.dump(scene_data, f, indent=2)
new_scene = Scene(
scene_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json",
name=name, data=scene_data
)
db.session.add(new_scene)
db.session.commit()
flash('Scene created successfully!')
return redirect(url_for('scene_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create scene: {e}")
return redirect(request.url)
return render_template('scenes/create.html')
@app.route('/scene/<path:slug>/clone', methods=['POST'])
def clone_scene(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
base_id = scene.scene_id
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
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['SCENES_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = scene.data.copy()
new_data['scene_id'] = new_id
new_data['scene_name'] = f"{scene.name} (Copy)"
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_scene = Scene(
scene_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['scene_name'], data=new_data
)
db.session.add(new_scene)
db.session.commit()
flash(f'Scene cloned as "{new_id}"!')
return redirect(url_for('scene_detail', slug=new_slug))
@app.route('/scene/<path:slug>/save_json', methods=['POST'])
def save_scene_json(slug):
scene = Scene.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
scene.data = new_data
flag_modified(scene, 'data')
db.session.commit()
if scene.filename:
file_path = os.path.join(app.config['SCENES_DIR'], scene.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}

229
routes/settings.py Normal file
View File

@@ -0,0 +1,229 @@
import json
import logging
import subprocess
import requests
from flask import flash, redirect, render_template, request, session, url_for
from models import Checkpoint, Settings, db
logger = logging.getLogger('gaze')
def register_routes(app):
@app.context_processor
def inject_comfyui_ws_url():
url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188')
# If the URL is localhost/127.0.0.1, replace it with the current request's host
# so that remote clients connect to the correct machine for WebSockets.
if '127.0.0.1' in url or 'localhost' in url:
host = request.host.split(':')[0]
url = url.replace('127.0.0.1', host).replace('localhost', host)
# Convert http/https to ws/wss
ws_url = url.replace('http://', 'ws://').replace('https://', 'wss://')
return dict(COMFYUI_WS_URL=f"{ws_url}/ws")
@app.context_processor
def inject_default_checkpoint():
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
return dict(all_checkpoints=checkpoints, default_checkpoint_path=session.get('default_checkpoint', ''))
@app.route('/set_default_checkpoint', methods=['POST'])
def set_default_checkpoint():
checkpoint_path = request.form.get('checkpoint_path', '')
session['default_checkpoint'] = checkpoint_path
session.modified = True
# Persist to database Settings so it survives across server restarts
try:
settings = Settings.query.first()
if not settings:
settings = Settings()
db.session.add(settings)
settings.default_checkpoint = checkpoint_path
db.session.commit()
logger.info("Default checkpoint saved to database: %s", checkpoint_path)
except Exception as e:
logger.error(f"Failed to persist checkpoint to database: {e}")
db.session.rollback()
# Also persist to comfy_workflow.json for backwards compatibility
try:
workflow_path = 'comfy_workflow.json'
with open(workflow_path, 'r') as f:
workflow = json.load(f)
# Update node 4 (CheckpointLoaderSimple) with the new checkpoint
if '4' in workflow and 'inputs' in workflow['4']:
workflow['4']['inputs']['ckpt_name'] = checkpoint_path
with open(workflow_path, 'w') as f:
json.dump(workflow, f, indent=2)
except Exception as e:
logger.error(f"Failed to persist checkpoint to workflow file: {e}")
return {'status': 'ok'}
@app.route('/api/status/comfyui')
def api_status_comfyui():
"""Return whether ComfyUI is reachable."""
url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188')
try:
resp = requests.get(f'{url}/system_stats', timeout=3)
if resp.ok:
return {'status': 'ok'}
except Exception:
pass
return {'status': 'error'}
@app.route('/api/comfyui/loaded_checkpoint')
def api_comfyui_loaded_checkpoint():
"""Return the checkpoint name from the most recently completed ComfyUI job."""
url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188')
try:
resp = requests.get(f'{url}/history', timeout=3)
if not resp.ok:
return {'checkpoint': None}
history = resp.json()
if not history:
return {'checkpoint': None}
# Sort by timestamp descending, take the most recent job
latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', ''))
# Node "4" is the checkpoint loader in the workflow
nodes = latest.get('prompt', [None, None, {}])[2]
ckpt_name = nodes.get('4', {}).get('inputs', {}).get('ckpt_name')
return {'checkpoint': ckpt_name}
except Exception:
return {'checkpoint': None}
@app.route('/api/status/mcp')
def api_status_mcp():
"""Return whether the danbooru-mcp Docker container is running."""
try:
result = subprocess.run(
['docker', 'ps', '--filter', 'name=danbooru-mcp', '--format', '{{.Names}}'],
capture_output=True, text=True, timeout=5,
)
if 'danbooru-mcp' in result.stdout:
return {'status': 'ok'}
except Exception:
pass
return {'status': 'error'}
@app.route('/api/status/llm')
def api_status_llm():
"""Return whether the configured LLM provider is reachable."""
try:
settings = Settings.query.first()
if not settings:
return {'status': 'error', 'message': 'Settings not configured'}
is_local = settings.llm_provider != 'openrouter'
if not is_local:
# Check OpenRouter
if not settings.openrouter_api_key:
return {'status': 'error', 'message': 'API key not configured'}
# Try to fetch models list as a lightweight check
headers = {
"Authorization": f"Bearer {settings.openrouter_api_key}",
}
resp = requests.get("https://openrouter.ai/api/v1/models", headers=headers, timeout=5)
if resp.ok:
return {'status': 'ok', 'provider': 'OpenRouter'}
else:
# Check local provider (Ollama or LMStudio)
if not settings.local_base_url:
return {'status': 'error', 'message': 'Base URL not configured'}
# Try to reach the models endpoint
url = f"{settings.local_base_url.rstrip('/')}/models"
resp = requests.get(url, timeout=5)
if resp.ok:
return {'status': 'ok', 'provider': settings.llm_provider.title()}
except Exception as e:
return {'status': 'error', 'message': str(e)}
return {'status': 'error'}
@app.route('/api/status/character-mcp')
def api_status_character_mcp():
"""Return whether the character-mcp Docker container is running."""
try:
result = subprocess.run(
['docker', 'ps', '--format', '{{.Names}}'],
capture_output=True, text=True, timeout=5,
)
# Check if any container name contains 'character-mcp'
if any('character-mcp' in line for line in result.stdout.splitlines()):
return {'status': 'ok'}
except Exception:
pass
return {'status': 'error'}
@app.route('/get_openrouter_models', methods=['POST'])
def get_openrouter_models():
api_key = request.form.get('api_key')
if not api_key:
return {'error': 'API key is required'}, 400
headers = {"Authorization": f"Bearer {api_key}"}
try:
response = requests.get("https://openrouter.ai/api/v1/models", headers=headers)
response.raise_for_status()
models = response.json().get('data', [])
# Return simplified list of models
return {'models': [{'id': m['id'], 'name': m.get('name', m['id'])} for m in models]}
except Exception as e:
return {'error': str(e)}, 500
@app.route('/get_local_models', methods=['POST'])
def get_local_models():
base_url = request.form.get('base_url')
if not base_url:
return {'error': 'Base URL is required'}, 400
try:
response = requests.get(f"{base_url.rstrip('/')}/models")
response.raise_for_status()
models = response.json().get('data', [])
# Ollama/LMStudio often follow the same structure as OpenAI
return {'models': [{'id': m['id'], 'name': m.get('name', m['id'])} for m in models]}
except Exception as e:
return {'error': str(e)}, 500
@app.route('/settings', methods=['GET', 'POST'])
def settings():
settings = Settings.query.first()
if not settings:
settings = Settings()
db.session.add(settings)
db.session.commit()
if request.method == 'POST':
settings.llm_provider = request.form.get('llm_provider', 'openrouter')
settings.openrouter_api_key = request.form.get('api_key')
settings.openrouter_model = request.form.get('model')
settings.local_base_url = request.form.get('local_base_url')
settings.local_model = request.form.get('local_model')
settings.lora_dir_characters = request.form.get('lora_dir_characters') or settings.lora_dir_characters
settings.lora_dir_outfits = request.form.get('lora_dir_outfits') or settings.lora_dir_outfits
settings.lora_dir_actions = request.form.get('lora_dir_actions') or settings.lora_dir_actions
settings.lora_dir_styles = request.form.get('lora_dir_styles') or settings.lora_dir_styles
settings.lora_dir_scenes = request.form.get('lora_dir_scenes') or settings.lora_dir_scenes
settings.lora_dir_detailers = request.form.get('lora_dir_detailers') or settings.lora_dir_detailers
settings.checkpoint_dirs = request.form.get('checkpoint_dirs') or settings.checkpoint_dirs
db.session.commit()
flash('Settings updated successfully!')
return redirect(url_for('settings'))
return render_template('settings.html', settings=settings)

400
routes/strengths.py Normal file
View File

@@ -0,0 +1,400 @@
import json
import os
import logging
import random
from flask import request, session, current_app
from models import db, Character, Look, Outfit, Action, Style, Scene, Detailer
from sqlalchemy.orm.attributes import flag_modified
from services.prompts import build_prompt, _dedup_tags, _cross_dedup_prompts
from services.workflow import _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts
from services.job_queue import _enqueue_job
from services.comfyui import get_history, get_image
logger = logging.getLogger('gaze')
_STRENGTHS_MODEL_MAP = {
'characters': Character,
'looks': Look,
'outfits': Outfit,
'actions': Action,
'styles': Style,
'scenes': Scene,
'detailers': Detailer,
}
_CATEGORY_LORA_NODES = {
'characters': '16',
'looks': '16',
'outfits': '17',
'actions': '18',
'styles': '19',
'scenes': '19',
'detailers': '19',
}
_STRENGTHS_DATA_DIRS = {
'characters': 'CHARACTERS_DIR',
'looks': 'LOOKS_DIR',
'outfits': 'CLOTHING_DIR',
'actions': 'ACTIONS_DIR',
'styles': 'STYLES_DIR',
'scenes': 'SCENES_DIR',
'detailers': 'DETAILERS_DIR',
}
def register_routes(app):
def _get_character_data_without_lora(character):
"""Extract character data excluding LoRA to prevent activation in strengths gallery."""
if not character:
return None
return {k: v for k, v in character.data.items() if k != 'lora'}
def _build_strengths_prompts(category, entity, character, action=None, extra_positive=''):
"""Build main/face/hand prompt strings for the Strengths Gallery."""
if category == 'characters':
return build_prompt(entity.data, [], entity.default_fields)
if category == 'looks':
char_data_no_lora = _get_character_data_without_lora(character)
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': ''}
look_pos = entity.data.get('positive', '')
look_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
prefix_parts = [p for p in [look_triggers, look_pos] if p]
prefix = ', '.join(prefix_parts)
if prefix:
base['main'] = f"{prefix}, {base['main']}" if base['main'] else prefix
return base
if category == 'outfits':
wardrobe = entity.data.get('wardrobe', {})
outfit_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
wardrobe_parts = [v for v in wardrobe.values() if isinstance(v, str) and v]
char_parts = []
face_parts = []
hand_parts = []
if character:
identity = character.data.get('identity', {})
defaults = character.data.get('defaults', {})
char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'),
identity.get('eyes'), defaults.get('expression')] if v]
face_parts = [v for v in [identity.get('hair'), identity.get('eyes'),
defaults.get('expression')] if v]
hand_parts = [v for v in [wardrobe.get('hands'), wardrobe.get('gloves')] if v]
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags
return {
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
'face': _dedup_tags(', '.join(face_parts)),
'hand': _dedup_tags(', '.join(hand_parts)),
}
if category == 'actions':
action_data = entity.data.get('action', {})
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional']
pose_parts = [action_data.get(k, '') for k in pose_fields if action_data.get(k)]
expr_parts = [action_data.get(k, '') for k in ['head', 'eyes'] if action_data.get(k)]
char_parts = []
face_parts = list(expr_parts)
hand_parts = [action_data.get('hands', '')] if action_data.get('hands') else []
if character:
identity = character.data.get('identity', {})
char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'),
identity.get('eyes')] if v]
face_parts = [v for v in [identity.get('hair'), identity.get('eyes')] + expr_parts if v]
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
return {
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
'face': _dedup_tags(', '.join(face_parts)),
'hand': _dedup_tags(', '.join(hand_parts)),
}
# styles / scenes / detailers
entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
if category == 'styles':
sdata = entity.data.get('style', {})
artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else ''
style_tags = sdata.get('artistic_style', '')
entity_parts = [p for p in [entity_triggers, artist, style_tags] + tags if p]
elif category == 'scenes':
sdata = entity.data.get('scene', {})
scene_parts = [v for v in sdata.values() if isinstance(v, str) and v]
entity_parts = [p for p in [entity_triggers] + scene_parts + tags if p]
else: # detailers
det_prompt = entity.data.get('prompt', '')
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p]
char_data_no_lora = _get_character_data_without_lora(character)
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': ''}
entity_str = ', '.join(entity_parts)
if entity_str:
base['main'] = f"{base['main']}, {entity_str}" if base['main'] else entity_str
if action is not None:
action_data = action.data.get('action', {})
action_parts = [action_data.get(k, '') for k in
['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional', 'head', 'eyes']
if action_data.get(k)]
action_str = ', '.join(action_parts)
if action_str:
base['main'] = f"{base['main']}, {action_str}" if base['main'] else action_str
if extra_positive:
base['main'] = f"{base['main']}, {extra_positive}" if base['main'] else extra_positive
return base
def _prepare_strengths_workflow(workflow, category, entity, character, prompts,
checkpoint, ckpt_data, strength_value, fixed_seed,
custom_negative=''):
"""Wire a ComfyUI workflow with ONLY the entity's LoRA active at a specific strength."""
active_node = _CATEGORY_LORA_NODES.get(category, '16')
entity_lora = entity.data.get('lora', {})
entity_lora_name = entity_lora.get('lora_name', '')
if checkpoint and '4' in workflow:
workflow['4']['inputs']['ckpt_name'] = checkpoint
if '5' in workflow:
workflow['5']['inputs']['width'] = 1024
workflow['5']['inputs']['height'] = 1024
if '6' in workflow:
workflow['6']['inputs']['text'] = workflow['6']['inputs']['text'].replace(
'{{POSITIVE_PROMPT}}', prompts.get('main', ''))
if '14' in workflow:
workflow['14']['inputs']['text'] = workflow['14']['inputs']['text'].replace(
'{{FACE_PROMPT}}', prompts.get('face', ''))
if '15' in workflow:
workflow['15']['inputs']['text'] = workflow['15']['inputs']['text'].replace(
'{{HAND_PROMPT}}', prompts.get('hand', ''))
if category == 'looks':
look_neg = entity.data.get('negative', '')
if look_neg and '7' in workflow:
workflow['7']['inputs']['text'] = f"{look_neg}, {workflow['7']['inputs']['text']}"
if custom_negative and '7' in workflow:
workflow['7']['inputs']['text'] = f"{custom_negative}, {workflow['7']['inputs']['text']}"
model_source = ['4', 0]
clip_source = ['4', 1]
for node_id in ['16', '17', '18', '19']:
if node_id not in workflow:
continue
if node_id == active_node and entity_lora_name:
workflow[node_id]['inputs']['lora_name'] = entity_lora_name
workflow[node_id]['inputs']['strength_model'] = float(strength_value)
workflow[node_id]['inputs']['strength_clip'] = float(strength_value)
workflow[node_id]['inputs']['model'] = list(model_source)
workflow[node_id]['inputs']['clip'] = list(clip_source)
model_source = [node_id, 0]
clip_source = [node_id, 1]
for consumer, needs_model, needs_clip in [
('3', True, False),
('6', False, True),
('7', False, True),
('11', True, True),
('13', True, True),
('14', False, True),
('15', False, True),
]:
if consumer in workflow:
if needs_model:
workflow[consumer]['inputs']['model'] = list(model_source)
if needs_clip:
workflow[consumer]['inputs']['clip'] = list(clip_source)
for seed_node in ['3', '11', '13']:
if seed_node in workflow:
workflow[seed_node]['inputs']['seed'] = int(fixed_seed)
if ckpt_data:
workflow = _apply_checkpoint_settings(workflow, ckpt_data)
sampler_name = workflow['3']['inputs'].get('sampler_name')
scheduler = workflow['3']['inputs'].get('scheduler')
for node_id in ['11', '13']:
if node_id in workflow:
if sampler_name:
workflow[node_id]['inputs']['sampler_name'] = sampler_name
if scheduler:
workflow[node_id]['inputs']['scheduler'] = scheduler
pos_text, neg_text = _cross_dedup_prompts(
workflow['6']['inputs']['text'],
workflow['7']['inputs']['text']
)
workflow['6']['inputs']['text'] = pos_text
workflow['7']['inputs']['text'] = neg_text
_log_workflow_prompts(f"_prepare_strengths_workflow [node={active_node} lora={entity_lora_name} @ {strength_value} seed={fixed_seed}]", workflow)
return workflow
@app.route('/strengths/<category>/<path:slug>/generate', methods=['POST'])
def strengths_generate(category, slug):
if category not in _STRENGTHS_MODEL_MAP:
return {'error': 'unknown category'}, 400
Model = _STRENGTHS_MODEL_MAP[category]
entity = Model.query.filter_by(slug=slug).first_or_404()
try:
strength_value = float(request.form.get('strength_value', 1.0))
fixed_seed = int(request.form.get('seed', random.randint(1, 10**15)))
_singular = {
'outfits': 'outfit', 'actions': 'action', 'styles': 'style',
'scenes': 'scene', 'detailers': 'detailer', 'looks': 'look',
}
session_prefix = _singular.get(category, category)
char_slug = (request.form.get('character_slug') or
session.get(f'char_{session_prefix}_{slug}'))
if category == 'characters':
character = entity
elif char_slug == '__random__':
character = Character.query.order_by(db.func.random()).first()
elif char_slug:
character = Character.query.filter_by(slug=char_slug).first()
else:
character = None
print(f"[Strengths] char_slug={char_slug!r} → character={character.slug if character else 'none'}")
action_obj = None
extra_positive = ''
extra_negative = ''
if category == 'detailers':
action_slug = session.get(f'action_detailer_{slug}')
if action_slug:
action_obj = Action.query.filter_by(slug=action_slug).first()
extra_positive = session.get(f'extra_pos_detailer_{slug}', '')
extra_negative = session.get(f'extra_neg_detailer_{slug}', '')
print(f"[Strengths] detailer session — char={char_slug}, action={action_slug}, extra_pos={bool(extra_positive)}, extra_neg={bool(extra_negative)}")
prompts = _build_strengths_prompts(category, entity, character,
action=action_obj, extra_positive=extra_positive)
checkpoint, ckpt_data = _get_default_checkpoint()
workflow_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'comfy_workflow.json')
with open(workflow_path, 'r') as f:
workflow = json.load(f)
workflow = _prepare_strengths_workflow(
workflow, category, entity, character, prompts,
checkpoint, ckpt_data, strength_value, fixed_seed,
custom_negative=extra_negative
)
_category = category
_slug = slug
_strength_value = strength_value
_fixed_seed = fixed_seed
def _finalize(comfy_prompt_id, job):
history = get_history(comfy_prompt_id)
outputs = history[comfy_prompt_id].get('outputs', {})
img_data = None
for node_output in outputs.values():
for img in node_output.get('images', []):
img_data = get_image(img['filename'], img.get('subfolder', ''), img.get('type', 'output'))
break
if img_data:
break
if not img_data:
raise Exception('no image in output')
strength_str = f"{_strength_value:.2f}".replace('.', '_')
upload_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], _category, _slug, 'strengths')
os.makedirs(upload_dir, exist_ok=True)
out_filename = f"strength_{strength_str}_seed_{_fixed_seed}.png"
out_path = os.path.join(upload_dir, out_filename)
with open(out_path, 'wb') as f:
f.write(img_data)
relative = f"{_category}/{_slug}/strengths/{out_filename}"
job['result'] = {'image_url': f"/static/uploads/{relative}", 'strength_value': _strength_value}
label = f"Strengths: {entity.name} @ {strength_value:.2f}"
job = _enqueue_job(label, workflow, _finalize)
return {'status': 'queued', 'job_id': job['id']}
except Exception as e:
print(f"[Strengths] generate error: {e}")
return {'error': str(e)}, 500
@app.route('/strengths/<category>/<path:slug>/list')
def strengths_list(category, slug):
upload_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], category, slug, 'strengths')
if not os.path.isdir(upload_dir):
return {'images': []}
images = []
for fname in sorted(os.listdir(upload_dir)):
if not fname.endswith('.png'):
continue
try:
parts = fname.replace('strength_', '').split('_seed_')
strength_raw = parts[0]
strength_display = strength_raw.replace('_', '.')
except Exception:
strength_display = fname
images.append({
'url': f"/static/uploads/{category}/{slug}/strengths/{fname}",
'strength': strength_display,
'filename': fname,
})
return {'images': images}
@app.route('/strengths/<category>/<path:slug>/clear', methods=['POST'])
def strengths_clear(category, slug):
upload_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], category, slug, 'strengths')
if os.path.isdir(upload_dir):
for fname in os.listdir(upload_dir):
fpath = os.path.join(upload_dir, fname)
if os.path.isfile(fpath):
os.remove(fpath)
return {'success': True}
@app.route('/strengths/<category>/<path:slug>/save_range', methods=['POST'])
def strengths_save_range(category, slug):
"""Save lora_weight_min / lora_weight_max from the Strengths Gallery back to the entity JSON + DB."""
if category not in _STRENGTHS_MODEL_MAP or category not in _STRENGTHS_DATA_DIRS:
return {'error': 'unknown category'}, 400
try:
min_w = float(request.form.get('min_weight', ''))
max_w = float(request.form.get('max_weight', ''))
except (ValueError, TypeError):
return {'error': 'invalid weight values'}, 400
if min_w > max_w:
min_w, max_w = max_w, min_w
Model = _STRENGTHS_MODEL_MAP[category]
entity = Model.query.filter_by(slug=slug).first_or_404()
data = dict(entity.data)
if 'lora' not in data or not isinstance(data.get('lora'), dict):
return {'error': 'entity has no lora section'}, 400
data['lora']['lora_weight_min'] = min_w
data['lora']['lora_weight_max'] = max_w
entity.data = data
flag_modified(entity, 'data')
data_dir = current_app.config[_STRENGTHS_DATA_DIRS[category]]
filename = getattr(entity, 'filename', None) or f"{slug}.json"
file_path = os.path.join(data_dir, filename)
if os.path.exists(file_path):
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
db.session.commit()
return {'success': True, 'lora_weight_min': min_w, 'lora_weight_max': max_w}

544
routes/styles.py Normal file
View File

@@ -0,0 +1,544 @@
import json
import os
import re
import random
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, Style, Detailer, Settings
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_styles
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _WARDROBE_KEYS
logger = logging.getLogger('gaze')
def register_routes(app):
def _build_style_workflow(style_obj, character=None, selected_fields=None, fixed_seed=None, extra_positive=None, extra_negative=None):
"""Build and return a prepared ComfyUI workflow dict for a style generation."""
if character:
combined_data = character.data.copy()
combined_data['character_id'] = character.character_id
combined_data['style'] = style_obj.data.get('style', {})
# Merge style lora triggers if present
style_lora = style_obj.data.get('lora', {})
if style_lora.get('lora_triggers'):
if 'lora' not in combined_data: combined_data['lora'] = {}
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {style_lora['lora_triggers']}"
# Merge character identity and wardrobe fields into selected_fields
if selected_fields:
_ensure_character_fields(character, selected_fields)
else:
# Auto-include essential character fields (minimal set for batch/default generation)
selected_fields = []
for key in ['base_specs', 'hair', 'eyes']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
selected_fields.append('special::name')
wardrobe = character.get_active_wardrobe()
for key in _WARDROBE_KEYS:
if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}')
selected_fields.extend(['style::artist_name', 'style::artistic_style', 'lora::lora_triggers'])
default_fields = style_obj.default_fields
active_outfit = character.active_outfit
else:
combined_data = {
'character_id': style_obj.style_id,
'style': style_obj.data.get('style', {}),
'lora': style_obj.data.get('lora', {}),
'tags': style_obj.data.get('tags', [])
}
if not selected_fields:
selected_fields = ['style::artist_name', 'style::artistic_style', 'lora::lora_triggers']
default_fields = style_obj.default_fields
active_outfit = 'default'
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
_append_background(prompts, character)
if extra_positive:
prompts["main"] = f"{prompts['main']}, {extra_positive}"
ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, style=style_obj, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed)
return workflow
@app.route('/styles')
def styles_index():
styles = Style.query.order_by(Style.name).all()
return render_template('styles/index.html', styles=styles)
@app.route('/styles/rescan', methods=['POST'])
def rescan_styles():
sync_styles()
flash('Database synced with style files.')
return redirect(url_for('styles_index'))
@app.route('/style/<path:slug>')
def style_detail(slug):
style = Style.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_style_{slug}')
preview_image = session.get(f'preview_style_{slug}')
selected_character = session.get(f'char_style_{slug}')
extra_positive = session.get(f'extra_pos_style_{slug}', '')
extra_negative = session.get(f'extra_neg_style_{slug}', '')
# List existing preview images
upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{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"styles/{slug}/{f}" for f in files]
return render_template('styles/detail.html', style=style, characters=characters,
preferences=preferences, preview_image=preview_image,
selected_character=selected_character, existing_previews=existing_previews,
extra_positive=extra_positive, extra_negative=extra_negative)
@app.route('/style/<path:slug>/edit', methods=['GET', 'POST'])
def edit_style(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
loras = get_available_loras('styles')
if request.method == 'POST':
try:
# 1. Update basic fields
style.name = request.form.get('style_name')
# 2. Rebuild the data dictionary
new_data = style.data.copy()
new_data['style_name'] = style.name
# Update style section
if 'style' in new_data:
for key in new_data['style'].keys():
form_key = f"style_{key}"
if form_key in request.form:
new_data['style'][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 = 1.0
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)
style.data = new_data
flag_modified(style, "data")
# 3. Write back to JSON file
style_file = style.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', style.style_id)}.json"
file_path = os.path.join(app.config['STYLES_DIR'], style_file)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
db.session.commit()
flash('Style updated successfully!')
return redirect(url_for('style_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
flash(f"Error saving changes: {str(e)}")
return render_template('styles/edit.html', style=style, loras=loras)
@app.route('/style/<path:slug>/upload', methods=['POST'])
def upload_style_image(slug):
style = Style.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 style subfolder
style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}")
os.makedirs(style_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(style_folder, filename)
file.save(file_path)
# Store relative path in DB
style.image_path = f"styles/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('style_detail', slug=slug))
@app.route('/style/<path:slug>/generate', methods=['POST'])
def generate_style_image(slug):
style_obj = Style.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'char_style_{slug}'] = character_slug
session[f'prefs_style_{slug}'] = selected_fields
session[f'extra_pos_style_{slug}'] = extra_positive
session[f'extra_neg_style_{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 using helper (returns workflow dict, not prompt_response)
workflow = _build_style_workflow(style_obj, character, selected_fields, fixed_seed=fixed_seed, extra_positive=extra_positive, extra_negative=extra_negative)
char_label = character.name if character else 'no character'
label = f"Style: {style_obj.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('styles', slug, Style, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('style_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('style_detail', slug=slug))
@app.route('/style/<path:slug>/save_defaults', methods=['POST'])
def save_style_defaults(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
style.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this style!')
return redirect(url_for('style_detail', slug=slug))
@app.route('/style/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_style_cover_from_preview(slug):
style = Style.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)):
style.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('style_detail', slug=slug))
@app.route('/get_missing_styles')
def get_missing_styles():
missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.name).all()
return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]}
@app.route('/get_missing_detailers')
def get_missing_detailers():
missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.name).all()
return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]}
@app.route('/clear_all_detailer_covers', methods=['POST'])
def clear_all_detailer_covers():
detailers = Detailer.query.all()
for detailer in detailers:
detailer.image_path = None
db.session.commit()
return {'success': True}
@app.route('/clear_all_style_covers', methods=['POST'])
def clear_all_style_covers():
styles = Style.query.all()
for style in styles:
style.image_path = None
db.session.commit()
return {'success': True}
@app.route('/styles/generate_missing', methods=['POST'])
def generate_missing_styles():
missing = Style.query.filter(
(Style.image_path == None) | (Style.image_path == '')
).order_by(Style.name).all()
if not missing:
flash("No styles missing cover images.")
return redirect(url_for('styles_index'))
all_characters = Character.query.all()
if not all_characters:
flash("No characters available to preview styles with.", "error")
return redirect(url_for('styles_index'))
enqueued = 0
for style_obj in missing:
character = random.choice(all_characters)
try:
workflow = _build_style_workflow(style_obj, character=character)
_enqueue_job(f"Style: {style_obj.name} cover", workflow,
_make_finalize('styles', style_obj.slug, Style))
enqueued += 1
except Exception as e:
print(f"Error queuing cover generation for style {style_obj.name}: {e}")
flash(f"Queued {enqueued} style cover image generation job{'s' if enqueued != 1 else ''}.")
return redirect(url_for('styles_index'))
@app.route('/styles/bulk_create', methods=['POST'])
def bulk_create_styles_from_loras():
_s = Settings.query.first()
styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/')
_lora_subfolder = os.path.basename(styles_lora_dir)
if not os.path.exists(styles_lora_dir):
flash('Styles LoRA directory not found.', 'error')
return redirect(url_for('styles_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('style_system.txt')
if not system_prompt:
flash('Style system prompt file not found.', 'error')
return redirect(url_for('styles_index'))
for filename in os.listdir(styles_lora_dir):
if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0]
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{style_id}.json"
json_path = os.path.join(app.config['STYLES_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(styles_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'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', 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 style: {style_name}")
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
style_data = json.loads(clean_json)
style_data['style_id'] = style_id
style_data['style_name'] = style_name
if 'lora' not in style_data: style_data['lora'] = {}
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not style_data['lora'].get('lora_triggers'):
style_data['lora']['lora_triggers'] = name_base
if style_data['lora'].get('lora_weight') is None:
style_data['lora']['lora_weight'] = 1.0
if style_data['lora'].get('lora_weight_min') is None:
style_data['lora']['lora_weight_min'] = 0.7
if style_data['lora'].get('lora_weight_max') is None:
style_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f:
json.dump(style_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 style for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
sync_styles()
msg = f'Successfully processed styles: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No styles created or overwritten. {skipped_count} existing styles found.')
return redirect(url_for('styles_index'))
@app.route('/style/create', methods=['GET', 'POST'])
def create_style():
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug)
if not safe_slug:
safe_slug = 'style'
base_slug = safe_slug
counter = 1
while os.path.exists(os.path.join(app.config['STYLES_DIR'], f"{safe_slug}.json")):
safe_slug = f"{base_slug}_{counter}"
counter += 1
style_data = {
"style_id": safe_slug,
"style_name": name,
"style": {
"artist_name": "",
"artistic_style": ""
},
"lora": {
"lora_name": "",
"lora_weight": 1.0,
"lora_triggers": ""
}
}
try:
file_path = os.path.join(app.config['STYLES_DIR'], f"{safe_slug}.json")
with open(file_path, 'w') as f:
json.dump(style_data, f, indent=2)
new_style = Style(
style_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json",
name=name, data=style_data
)
db.session.add(new_style)
db.session.commit()
flash('Style created successfully!')
return redirect(url_for('style_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create style: {e}")
return redirect(request.url)
return render_template('styles/create.html')
@app.route('/style/<path:slug>/clone', methods=['POST'])
def clone_style(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
base_id = style.style_id
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
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['STYLES_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = style.data.copy()
new_data['style_id'] = new_id
new_data['style_name'] = f"{style.name} (Copy)"
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_style = Style(
style_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['style_name'], data=new_data
)
db.session.add(new_style)
db.session.commit()
flash(f'Style cloned as "{new_id}"!')
return redirect(url_for('style_detail', slug=new_slug))
@app.route('/style/<path:slug>/save_json', methods=['POST'])
def save_style_json(slug):
style = Style.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
style.data = new_data
flag_modified(style, 'data')
db.session.commit()
if style.filename:
file_path = os.path.join(app.config['STYLES_DIR'], style.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}

340
routes/transfer.py Normal file
View File

@@ -0,0 +1,340 @@
import json
import os
import re
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from models import db, Character, Look, Outfit, Action, Style, Scene, Detailer, Settings
from sqlalchemy.orm.attributes import flag_modified
from utils import _LORA_DEFAULTS
from services.llm import load_prompt, call_llm
logger = logging.getLogger('gaze')
_RESOURCE_TRANSFER_MAP = {
'looks': {
'model_class': Look,
'id_field': 'look_id',
'target_dir': 'LOOKS_DIR',
'index_route': 'looks_index',
'detail_route': 'look_detail',
'name_key': 'look_name',
'id_key': 'look_id',
},
'outfits': {
'model_class': Outfit,
'id_field': 'outfit_id',
'target_dir': 'CLOTHING_DIR',
'index_route': 'outfits_index',
'detail_route': 'outfit_detail',
'name_key': 'outfit_name',
'id_key': 'outfit_id',
},
'actions': {
'model_class': Action,
'id_field': 'action_id',
'target_dir': 'ACTIONS_DIR',
'index_route': 'actions_index',
'detail_route': 'action_detail',
'name_key': 'action_name',
'id_key': 'action_id',
},
'styles': {
'model_class': Style,
'id_field': 'style_id',
'target_dir': 'STYLES_DIR',
'index_route': 'styles_index',
'detail_route': 'style_detail',
'name_key': 'style_name',
'id_key': 'style_id',
},
'scenes': {
'model_class': Scene,
'id_field': 'scene_id',
'target_dir': 'SCENES_DIR',
'index_route': 'scenes_index',
'detail_route': 'scene_detail',
'name_key': 'scene_name',
'id_key': 'scene_id',
},
'detailers': {
'model_class': Detailer,
'id_field': 'detailer_id',
'target_dir': 'DETAILERS_DIR',
'index_route': 'detailers_index',
'detail_route': 'detailer_detail',
'name_key': 'detailer_name',
'id_key': 'detailer_id',
},
}
_TRANSFER_TARGET_CATEGORIES = ['looks', 'outfits', 'actions', 'styles', 'scenes', 'detailers']
def register_routes(app):
def _create_minimal_template(target_category, slug, name, source_data, source_name='resource'):
"""Create a minimal template for the target category with basic fields."""
templates = {
'looks': {
'look_id': slug,
'look_name': name,
'positive': source_data.get('positive', ''),
'negative': source_data.get('negative', ''),
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 0.8, 'lora_triggers': ''}),
'tags': source_data.get('tags', []),
'description': f"Transferred from {source_name}"
},
'outfits': {
'outfit_id': slug,
'outfit_name': name,
'wardrobe': source_data.get('wardrobe', {
'full_body': '', 'headwear': '', 'top': '', 'bottom': '',
'legwear': '', 'footwear': '', 'hands': '', 'accessories': ''
}),
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 0.8, 'lora_triggers': ''}),
'tags': source_data.get('tags', []),
'description': f"Transferred from {source_name}"
},
'actions': {
'action_id': slug,
'action_name': name,
'action': source_data.get('action', {
'full_body': '', 'head': '', 'eyes': '', 'arms': '', 'hands': '',
'torso': '', 'pelvis': '', 'legs': '', 'feet': '', 'additional': ''
}),
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}),
'tags': source_data.get('tags', []),
'description': f"Transferred from {source_name}"
},
'styles': {
'style_id': slug,
'style_name': name,
'style': source_data.get('style', {'artist_name': '', 'artistic_style': ''}),
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}),
'tags': source_data.get('tags', []),
'description': f"Transferred from {source_name}"
},
'scenes': {
'scene_id': slug,
'scene_name': name,
'scene': source_data.get('scene', {
'background': '', 'foreground': '', 'furniture': [],
'colors': [], 'lighting': '', 'theme': ''
}),
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}),
'tags': source_data.get('tags', []),
'description': f"Transferred from {source_name}"
},
'detailers': {
'detailer_id': slug,
'detailer_name': name,
'prompt': source_data.get('prompt', source_data.get('positive', '')),
'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}),
'tags': source_data.get('tags', []),
'description': f"Transferred from {source_name}"
},
}
return templates.get(target_category, {
f'{target_category.rstrip("s")}_id': slug,
f'{target_category.rstrip("s")}_name': name,
'description': f"Transferred from {source_name}",
'tags': source_data.get('tags', []),
'lora': source_data.get('lora', {})
})
@app.route('/resource/<category>/<slug>/transfer', methods=['GET', 'POST'])
def transfer_resource(category, slug):
"""Generic resource transfer route."""
if category not in _RESOURCE_TRANSFER_MAP:
flash(f'Invalid category: {category}')
return redirect(url_for('index'))
source_config = _RESOURCE_TRANSFER_MAP[category]
model_class = source_config['model_class']
id_field = source_config['id_field']
resource = model_class.query.filter_by(slug=slug).first_or_404()
resource_name = resource.name
resource_data = resource.data
if request.method == 'POST':
target_category = request.form.get('target_category')
new_name = request.form.get('new_name', '').strip()
new_id = request.form.get('new_id', '').strip()
use_llm = request.form.get('use_llm') in ('on', 'true', '1', 'yes') or request.form.get('use_llm') is not None
transfer_lora = request.form.get('transfer_lora') == 'on'
remove_original = request.form.get('remove_original') == 'on'
if not new_name:
flash('New name is required for transfer')
return redirect(url_for('transfer_resource', category=category, slug=slug))
if len(new_name) > 100:
flash('New name must be 100 characters or less')
return redirect(url_for('transfer_resource', category=category, slug=slug))
if not new_id:
new_id = re.sub(r'[^a-zA-Z0-9]+', '_', new_name.lower()).strip('_')
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
if not safe_slug:
safe_slug = 'transferred'
if target_category not in _RESOURCE_TRANSFER_MAP:
flash('Invalid target category')
return redirect(url_for('transfer_resource', category=category, slug=slug))
if target_category == category:
flash('Cannot transfer to the same category')
return redirect(url_for('transfer_resource', category=category, slug=slug))
target_config = _RESOURCE_TRANSFER_MAP[target_category]
target_model_class = target_config['model_class']
target_id_field = target_config['id_field']
target_dir = current_app.config[target_config['target_dir']]
target_name_key = target_config['name_key']
target_id_key = target_config['id_key']
base_slug = safe_slug
counter = 1
while os.path.exists(os.path.join(target_dir, f"{safe_slug}.json")):
safe_slug = f"{base_slug}_{counter}"
counter += 1
if use_llm:
try:
category_system_prompts = {
'outfits': 'outfit_system.txt',
'actions': 'action_system.txt',
'styles': 'style_system.txt',
'scenes': 'scene_system.txt',
'detailers': 'detailer_system.txt',
'looks': 'look_system.txt',
}
system_prompt_file = category_system_prompts.get(target_category)
system_prompt = load_prompt(system_prompt_file) if system_prompt_file else None
if not system_prompt:
system_prompt = load_prompt('transfer_system.txt')
if not system_prompt:
system_prompt = f"""You are an AI assistant that creates {target_category.rstrip('s')} profiles for AI image generation.
Your task is to create a {target_category.rstrip('s')} profile based on the source resource data provided.
Target type: {target_category}
Required JSON Structure:
- {target_id_key}: "{safe_slug}"
- {target_name_key}: "{new_name}"
"""
source_summary = json.dumps(resource_data, indent=2)
llm_prompt = f"""Create a {target_category.rstrip('s')} profile named "{new_name}" (ID: {safe_slug}) based on this source {category.rstrip('s')} data:
Source {category.rstrip('s')} name: {resource_name}
Source data:
{source_summary}
Generate a complete {target_category.rstrip('s')} profile with all required fields for the {target_category} category."""
llm_response = call_llm(llm_prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
new_data = json.loads(clean_json)
new_data[target_id_key] = safe_slug
new_data[target_name_key] = new_name
except Exception as e:
logger.exception(f"LLM transfer error: {e}")
flash(f'Failed to generate {target_category.rstrip("s")} with AI: {e}')
return redirect(url_for('transfer_resource', category=category, slug=slug))
else:
new_data = _create_minimal_template(target_category, safe_slug, new_name, resource_data, resource_name)
try:
settings = Settings.query.first()
lora_moved = False
if transfer_lora and 'lora' in resource_data and resource_data['lora'].get('lora_name'):
old_lora_path = resource_data['lora']['lora_name']
lora_dir_map = {
'characters': getattr(settings, 'lora_dir_characters', None) or _LORA_DEFAULTS.get('characters', ''),
'looks': getattr(settings, 'lora_dir_looks', None) or getattr(settings, 'lora_dir_characters', None) or _LORA_DEFAULTS.get('looks', _LORA_DEFAULTS.get('characters', '')),
'outfits': getattr(settings, 'lora_dir_outfits', None) or _LORA_DEFAULTS.get('outfits', ''),
'actions': getattr(settings, 'lora_dir_actions', None) or _LORA_DEFAULTS.get('actions', ''),
'styles': getattr(settings, 'lora_dir_styles', None) or _LORA_DEFAULTS.get('styles', ''),
'scenes': getattr(settings, 'lora_dir_scenes', None) or _LORA_DEFAULTS.get('scenes', ''),
'detailers': getattr(settings, 'lora_dir_detailers', None) or _LORA_DEFAULTS.get('detailers', ''),
}
target_lora_dir = lora_dir_map.get(target_category)
source_lora_dir = lora_dir_map.get(category)
if old_lora_path and target_lora_dir and source_lora_dir:
lora_filename = os.path.basename(old_lora_path)
abs_source_path = os.path.join('/ImageModels/lora', old_lora_path)
if not os.path.exists(abs_source_path):
abs_source_path = os.path.join(source_lora_dir, lora_filename)
abs_target_path = os.path.join(target_lora_dir, lora_filename)
if os.path.exists(abs_source_path):
try:
import shutil
os.makedirs(target_lora_dir, exist_ok=True)
shutil.move(abs_source_path, abs_target_path)
target_subfolder = os.path.basename(target_lora_dir.rstrip('/'))
new_data['lora']['lora_name'] = f"Illustrious/{target_subfolder}/{lora_filename}"
lora_moved = True
flash(f'Moved LoRA file to {target_lora_dir}')
except Exception as lora_e:
logger.exception(f"LoRA move error: {lora_e}")
flash(f'Warning: Failed to move LoRA file: {lora_e}', 'warning')
else:
flash(f'Warning: Source LoRA file not found at {abs_source_path}', 'warning')
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)
new_entity = target_model_class(
**{target_id_field: safe_slug},
slug=safe_slug,
filename=f"{safe_slug}.json",
name=new_name,
data=new_data
)
db.session.add(new_entity)
if remove_original:
try:
source_dir = current_app.config[source_config['target_dir']]
orig_file_path = os.path.join(source_dir, resource.filename or f"{resource.slug}.json")
if os.path.exists(orig_file_path):
os.remove(orig_file_path)
db.session.delete(resource)
flash(f'Removed original {category.rstrip("s")}: {resource_name}')
except Exception as rm_e:
logger.exception(f"Error removing original: {rm_e}")
flash(f'Warning: Failed to remove original: {rm_e}', 'warning')
db.session.commit()
flash(f'Successfully transferred to {target_category.rstrip("s")}: {new_name}')
return redirect(url_for(target_config['index_route'], highlight=safe_slug))
except Exception as e:
logger.exception(f"Transfer save error: {e}")
flash(f'Failed to save transferred {target_category.rstrip("s")}: {e}')
return redirect(url_for('transfer_resource', category=category, slug=slug))
available_targets = [(cat, cat.rstrip('s').replace('_', ' ').title())
for cat in _TRANSFER_TARGET_CATEGORIES
if cat != category]
return render_template('transfer_resource.html',
category=category,
resource=resource,
available_targets=available_targets,
cancel_route=source_config['detail_route'])