Files
character-browser/routes/gallery.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

333 lines
12 KiB
Python

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}