Files
character-browser/routes/transfer.py
Aodhan Collins 5e4348ebc1 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>
2026-03-13 02:07:16 +00:00

341 lines
16 KiB
Python

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