Files
character-browser/routes/presets.py
Aodhan Collins 29a6723b25 Code review fixes: wardrobe migration, response validation, path traversal guard, deduplication
- Migrate 11 character JSONs from old wardrobe keys to _BODY_GROUP_KEYS format
- Add is_favourite/is_nsfw columns to Preset model
- Add HTTP response validation and timeouts to ComfyUI client
- Add path traversal protection on replace cover route
- Deduplicate services/mcp.py (4 functions → 2 generic + 2 wrappers)
- Extract apply_library_filters() and clean_html_text() shared helpers
- Add named constants for 17 ComfyUI workflow node IDs
- Fix bare except clauses in services/llm.py
- Fix tags schema in ensure_default_outfit() (list → dict)
- Convert f-string logging to lazy % formatting
- Add 5-minute polling timeout to frontend waitForJob()
- Improve migration error handling (non-duplicate errors log at WARNING)
- Update CLAUDE.md to reflect all changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 00:31:27 +00:00

246 lines
12 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, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look
from sqlalchemy.orm.attributes import flag_modified
from services.sync import sync_presets
from services.generation import generate_from_preset
from services.llm import load_prompt, call_llm
from routes.shared import register_common_routes, apply_library_filters
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'presets')
@app.route('/presets')
def presets_index():
presets, fav, nsfw = apply_library_filters(Preset.query, Preset)
return render_template('presets/index.html', presets=presets,
favourite_filter=fav, nsfw_filter=nsfw)
@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')
extra_positive = request.form.get('extra_positive', '').strip()
extra_negative = request.form.get('extra_negative', '').strip()
# Save to session (web UI state)
session[f'extra_pos_preset_{slug}'] = extra_positive
session[f'extra_neg_preset_{slug}'] = extra_negative
session.modified = True
# Build overrides from form
seed_val = request.form.get('seed', '').strip()
form_width = request.form.get('width', '').strip()
form_height = request.form.get('height', '').strip()
overrides = {
'action': action,
'extra_positive': extra_positive,
'extra_negative': extra_negative,
'checkpoint': request.form.get('checkpoint_override', '').strip(),
'seed': int(seed_val) if seed_val else None,
'width': int(form_width) if form_width else None,
'height': int(form_height) if form_height else None,
}
job = generate_from_preset(preset, overrides)
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>/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', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
'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 ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
},
},
},
'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',
'suppress_wardrobe': {'true': True, 'false': False, 'random': 'random'}.get(
request.form.get('act_suppress_wardrobe')),
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
'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'))},
'resolution': {
'width': int(request.form.get('res_width', 1024)),
'height': int(request.form.get('res_height', 1024)),
'random': request.form.get('res_random') == 'on',
},
'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('/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():
form_data = {}
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
use_llm = request.form.get('use_llm') == 'on'
form_data = {'name': name, 'description': description, 'use_llm': use_llm}
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 render_template('presets/create.html', form_data=form_data)
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 render_template('presets/create.html', form_data=form_data)
else:
preset_data = {
'character': {'character_id': 'random', 'use_lora': True,
'fields': {
'identity': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']},
'defaults': {k: False for k in ['expression', 'pose', 'scene']},
'wardrobe': {'outfit': 'default',
'fields': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
}},
'outfit': {'outfit_id': None, 'use_lora': True},
'action': {'action_id': None, 'use_lora': True,
'fields': {k: True for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
'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},
'resolution': {'width': 1024, 'height': 1024, 'random': False},
'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', form_data=form_data)