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:
33
routes/__init__.py
Normal file
33
routes/__init__.py
Normal 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
617
routes/actions.py
Normal 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
873
routes/characters.py
Normal 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
286
routes/checkpoints.py
Normal 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
457
routes/detailers.py
Normal 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
332
routes/gallery.py
Normal 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
139
routes/generator.py
Normal 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
592
routes/looks.py
Normal 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
604
routes/outfits.py
Normal 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
439
routes/presets.py
Normal 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
98
routes/queue_api.py
Normal 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
540
routes/scenes.py
Normal 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
229
routes/settings.py
Normal 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
400
routes/strengths.py
Normal 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
544
routes/styles.py
Normal 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
340
routes/transfer.py
Normal 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'])
|
||||
Reference in New Issue
Block a user