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

541 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import json
import 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', 'head']:
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}