Add semantic tagging, search, favourite/NSFW filtering, and LLM job queue

Replaces old list-format tags (which duplicated prompt content) with structured
dict tags per category (origin_series, outfit_type, participants, style_type,
scene_type, etc.). Tags are now purely organizational metadata — removed from
the prompt pipeline entirely.

Adds is_favourite and is_nsfw columns to all 8 resource models. Favourite is
DB-only (user preference); NSFW is mirrored in JSON tags for rescan persistence.
All library pages get filter controls and favourites-first sorting.

Introduces a parallel LLM job queue (_enqueue_task + _llm_queue_worker) for
background tag regeneration, with the same status polling UI as ComfyUI jobs.
Fixes call_llm() to use has_request_context() fallback for background threads.

Adds global search (/search) across resources and gallery images, with navbar
search bar. Adds gallery image sidecar JSON for per-image favourite/NSFW metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-21 03:22:09 +00:00
parent 7d79e626a5
commit 32a73b02f5
72 changed files with 3163 additions and 2212 deletions

View File

@@ -1,154 +1,135 @@
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 flask import render_template, request, redirect, url_for, flash
from models import Preset
from services.generation import generate_from_preset
from services.file_io import get_available_checkpoints
from services.comfyui import get_loaded_checkpoint
from services.workflow import _get_default_checkpoint
from services.sync import _resolve_preset_entity
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/generator', methods=['GET', 'POST'])
@app.route('/generator', methods=['GET'])
def generator():
characters = Character.query.order_by(Character.name).all()
presets = Preset.query.order_by(Preset.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"]
# Default to whatever is currently loaded in ComfyUI, then settings default
selected_ckpt = get_loaded_checkpoint()
if not selected_ckpt:
default_path, _ = _get_default_checkpoint()
selected_ckpt = default_path
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', '')
# Pre-select preset from query param
preset_slug = request.args.get('preset', '')
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
return render_template('generator.html',
presets=presets,
checkpoints=checkpoints,
selected_ckpt=selected_ckpt,
preset_slug=preset_slug)
character = Character.query.filter_by(slug=char_slug).first_or_404()
@app.route('/generator/generate', methods=['POST'])
def generator_generate():
preset_slug = request.form.get('preset_slug', '').strip()
if not preset_slug:
return {'error': 'No preset selected'}, 400
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 []
preset = Preset.query.filter_by(slug=preset_slug).first()
if not preset:
return {'error': 'Preset not found'}, 404
try:
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
try:
overrides = {
'checkpoint': request.form.get('checkpoint', '').strip() or None,
'extra_positive': request.form.get('extra_positive', '').strip(),
'extra_negative': request.form.get('extra_negative', '').strip(),
'action': 'preview',
}
# Build base prompts from character defaults
prompts = build_prompt(character.data, default_fields=character.default_fields)
seed_val = request.form.get('seed', '').strip()
if seed_val:
overrides['seed'] = int(seed_val)
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"{custom_positive}, {combined}"
prompts["main"] = combined
width = request.form.get('width', '').strip()
height = request.form.get('height', '').strip()
if width and height:
overrides['width'] = int(width)
overrides['height'] = int(height)
# Apply face/hand prompt overrides if provided
override_face = request.form.get('override_face_prompt', '').strip()
override_hand = request.form.get('override_hand_prompt', '').strip()
if override_face:
prompts["face"] = override_face
if override_hand:
prompts["hand"] = override_hand
job = generate_from_preset(preset, overrides, save_category='generator')
# Parse optional seed
seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
# 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,
)
flash("Generation queued.")
return redirect(url_for('generator', preset=preset_slug))
print(f"Queueing generator prompt for {character.character_id}")
except Exception as e:
logger.exception("Generator error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error: {str(e)}")
return redirect(url_for('generator', preset=preset_slug))
_finalize = _make_finalize('characters', character.slug)
label = f"Generator: {character.name}"
job = _enqueue_job(label, workflow, _finalize)
@app.route('/generator/preset_info', methods=['GET'])
def generator_preset_info():
"""Return resolved entity names for a preset (for the summary panel)."""
slug = request.args.get('slug', '')
if not slug:
return {'error': 'slug required'}, 400
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
preset = Preset.query.filter_by(slug=slug).first()
if not preset:
return {'error': 'not found'}, 404
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)}")
data = preset.data
info = {}
return render_template('generator.html', characters=characters, checkpoints=checkpoints,
actions=actions, outfits=outfits, scenes=scenes,
styles=styles, detailers=detailers, selected_ckpt=selected_ckpt)
# Character
char_cfg = data.get('character', {})
char_id = char_cfg.get('character_id')
if char_id == 'random':
info['character'] = 'Random'
elif char_id:
obj = _resolve_preset_entity('character', char_id)
info['character'] = obj.name if obj else char_id
else:
info['character'] = None
@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
# Secondary entities
for key, label in [('outfit', 'outfit'), ('action', 'action'), ('style', 'style'),
('scene', 'scene'), ('detailer', 'detailer'), ('look', 'look')]:
cfg = data.get(key, {})
eid = cfg.get(f'{key}_id')
if eid == 'random':
info[label] = 'Random'
elif eid:
obj = _resolve_preset_entity(key, eid)
info[label] = obj.name if obj else eid
else:
info[label] = None
character = Character.query.filter_by(slug=char_slug).first()
if not character:
return {'error': 'Character not found'}, 404
# Checkpoint
ckpt_cfg = data.get('checkpoint', {})
ckpt_path = ckpt_cfg.get('checkpoint_path')
if ckpt_path == 'random':
info['checkpoint'] = 'Random'
elif ckpt_path:
info['checkpoint'] = ckpt_path.split('/')[-1].replace('.safetensors', '')
else:
info['checkpoint'] = 'Default'
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', '')
# Resolution
res_cfg = data.get('resolution', {})
if res_cfg.get('random'):
info['resolution'] = 'Random'
elif res_cfg.get('width') and res_cfg.get('height'):
info['resolution'] = f"{res_cfg['width']}x{res_cfg['height']}"
else:
info['resolution'] = 'Default'
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"{custom_positive}, {combined}"
return {'prompt': combined, 'face': prompts['face'], 'hand': prompts['hand']}
return info