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:
@@ -4,13 +4,13 @@ import logging
|
||||
|
||||
from flask import render_template, request, current_app
|
||||
from models import (
|
||||
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint,
|
||||
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint, Preset,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
|
||||
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints']
|
||||
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints', 'looks', 'presets', 'generator']
|
||||
|
||||
_MODEL_MAP = {
|
||||
'characters': Character,
|
||||
@@ -20,11 +20,36 @@ _MODEL_MAP = {
|
||||
'styles': Style,
|
||||
'detailers': Detailer,
|
||||
'checkpoints': Checkpoint,
|
||||
'looks': Look,
|
||||
'presets': Preset,
|
||||
'generator': Preset,
|
||||
}
|
||||
|
||||
# Maps xref_category param names to sidecar JSON keys
|
||||
_XREF_KEY_MAP = {
|
||||
'character': 'character_slug',
|
||||
'outfit': 'outfit_slug',
|
||||
'action': 'action_slug',
|
||||
'style': 'style_slug',
|
||||
'scene': 'scene_slug',
|
||||
'detailer': 'detailer_slug',
|
||||
'look': 'look_slug',
|
||||
'preset': 'preset_slug',
|
||||
}
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
def _read_sidecar(upload_folder, image_path):
|
||||
"""Read JSON sidecar for an image. Returns dict or None."""
|
||||
sidecar = image_path.rsplit('.', 1)[0] + '.json'
|
||||
sidecar_path = os.path.join(upload_folder, sidecar)
|
||||
try:
|
||||
with open(sidecar_path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
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']
|
||||
@@ -164,18 +189,48 @@ def register_routes(app):
|
||||
category = request.args.get('category', 'all')
|
||||
slug = request.args.get('slug', '')
|
||||
sort = request.args.get('sort', 'newest')
|
||||
xref_category = request.args.get('xref_category', '')
|
||||
xref_slug = request.args.get('xref_slug', '')
|
||||
favourite_filter = request.args.get('favourite', '')
|
||||
nsfw_filter = request.args.get('nsfw', 'all')
|
||||
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)
|
||||
|
||||
# Read sidecar data for filtering (favourite/NSFW/xref)
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
need_sidecar = (xref_category and xref_slug) or favourite_filter or nsfw_filter != 'all'
|
||||
if need_sidecar:
|
||||
for img in images:
|
||||
img['_sidecar'] = _read_sidecar(upload_folder, img['path']) or {}
|
||||
|
||||
# Cross-reference filter
|
||||
if xref_category and xref_slug and xref_category in _XREF_KEY_MAP:
|
||||
sidecar_key = _XREF_KEY_MAP[xref_category]
|
||||
images = [img for img in images if img.get('_sidecar', {}).get(sidecar_key) == xref_slug]
|
||||
|
||||
# Favourite filter
|
||||
if favourite_filter == 'on':
|
||||
images = [img for img in images if img.get('_sidecar', {}).get('is_favourite')]
|
||||
|
||||
# NSFW filter
|
||||
if nsfw_filter == 'sfw':
|
||||
images = [img for img in images if not img.get('_sidecar', {}).get('is_nsfw')]
|
||||
elif nsfw_filter == 'nsfw':
|
||||
images = [img for img in images if img.get('_sidecar', {}).get('is_nsfw')]
|
||||
|
||||
if sort == 'oldest':
|
||||
images.reverse()
|
||||
elif sort == 'random':
|
||||
import random
|
||||
random.shuffle(images)
|
||||
|
||||
# Sort favourites first when favourite filter not active but sort is newest/oldest
|
||||
if sort in ('newest', 'oldest') and not favourite_filter and need_sidecar:
|
||||
images.sort(key=lambda x: (not x.get('_sidecar', {}).get('is_favourite', False), images.index(x)))
|
||||
|
||||
total = len(images)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = min(page, total_pages)
|
||||
@@ -197,6 +252,11 @@ def register_routes(app):
|
||||
if Model:
|
||||
slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()]
|
||||
|
||||
# Attach sidecar data to page images for template use
|
||||
for img in page_images:
|
||||
if '_sidecar' not in img:
|
||||
img['_sidecar'] = _read_sidecar(os.path.abspath(app.config['UPLOAD_FOLDER']), img['path']) or {}
|
||||
|
||||
return render_template(
|
||||
'gallery.html',
|
||||
images=page_images,
|
||||
@@ -209,6 +269,10 @@ def register_routes(app):
|
||||
sort=sort,
|
||||
categories=GALLERY_CATEGORIES,
|
||||
slug_options=slug_options,
|
||||
xref_category=xref_category,
|
||||
xref_slug=xref_slug,
|
||||
favourite_filter=favourite_filter,
|
||||
nsfw_filter=nsfw_filter,
|
||||
)
|
||||
|
||||
@app.route('/gallery/prompt-data')
|
||||
@@ -228,8 +292,60 @@ def register_routes(app):
|
||||
|
||||
meta = _parse_comfy_png_metadata(abs_img)
|
||||
meta['path'] = img_path
|
||||
|
||||
# Include sidecar data if available (for cross-reference links)
|
||||
sidecar = _read_sidecar(upload_folder, img_path)
|
||||
if sidecar:
|
||||
meta['sidecar'] = sidecar
|
||||
|
||||
return meta
|
||||
|
||||
def _write_sidecar(upload_folder, image_path, data):
|
||||
"""Write/update JSON sidecar for an image."""
|
||||
sidecar = image_path.rsplit('.', 1)[0] + '.json'
|
||||
sidecar_path = os.path.join(upload_folder, sidecar)
|
||||
existing = {}
|
||||
try:
|
||||
with open(sidecar_path) as f:
|
||||
existing = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
existing.update(data)
|
||||
with open(sidecar_path, 'w') as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
|
||||
@app.route('/gallery/image/favourite', methods=['POST'])
|
||||
def gallery_image_favourite():
|
||||
"""Toggle favourite on a gallery image via sidecar JSON."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
img_path = data.get('path', '')
|
||||
if not img_path:
|
||||
return {'error': 'path required'}, 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) or not os.path.isfile(abs_img):
|
||||
return {'error': 'Invalid path'}, 400
|
||||
sidecar = _read_sidecar(upload_folder, img_path) or {}
|
||||
new_val = not sidecar.get('is_favourite', False)
|
||||
_write_sidecar(upload_folder, img_path, {'is_favourite': new_val})
|
||||
return {'success': True, 'is_favourite': new_val}
|
||||
|
||||
@app.route('/gallery/image/nsfw', methods=['POST'])
|
||||
def gallery_image_nsfw():
|
||||
"""Toggle NSFW on a gallery image via sidecar JSON."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
img_path = data.get('path', '')
|
||||
if not img_path:
|
||||
return {'error': 'path required'}, 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) or not os.path.isfile(abs_img):
|
||||
return {'error': 'Invalid path'}, 400
|
||||
sidecar = _read_sidecar(upload_folder, img_path) or {}
|
||||
new_val = not sidecar.get('is_nsfw', False)
|
||||
_write_sidecar(upload_folder, img_path, {'is_nsfw': new_val})
|
||||
return {'success': True, 'is_nsfw': new_val}
|
||||
|
||||
@app.route('/gallery/delete', methods=['POST'])
|
||||
def gallery_delete():
|
||||
"""Delete a generated image from the gallery. Only the image file is removed."""
|
||||
@@ -249,6 +365,10 @@ def register_routes(app):
|
||||
|
||||
if os.path.isfile(abs_img):
|
||||
os.remove(abs_img)
|
||||
# Also remove sidecar JSON if present
|
||||
sidecar = abs_img.rsplit('.', 1)[0] + '.json'
|
||||
if os.path.isfile(sidecar):
|
||||
os.remove(sidecar)
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
@@ -260,6 +380,7 @@ def register_routes(app):
|
||||
hard: removes JSON data file + LoRA/checkpoint safetensors + DB record.
|
||||
"""
|
||||
_RESOURCE_MODEL_MAP = {
|
||||
'characters': Character,
|
||||
'looks': Look,
|
||||
'styles': Style,
|
||||
'actions': Action,
|
||||
@@ -269,6 +390,7 @@ def register_routes(app):
|
||||
'checkpoints': Checkpoint,
|
||||
}
|
||||
_RESOURCE_DATA_DIRS = {
|
||||
'characters': app.config['CHARACTERS_DIR'],
|
||||
'looks': app.config['LOOKS_DIR'],
|
||||
'styles': app.config['STYLES_DIR'],
|
||||
'actions': app.config['ACTIONS_DIR'],
|
||||
|
||||
Reference in New Issue
Block a user