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:
173
routes/looks.py
173
routes/looks.py
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags
|
||||
from services.sync import sync_looks
|
||||
from services.file_io import get_available_loras, _count_look_assignments
|
||||
@@ -58,9 +57,18 @@ def register_routes(app):
|
||||
|
||||
@app.route('/looks')
|
||||
def looks_index():
|
||||
looks = Look.query.order_by(Look.name).all()
|
||||
query = Look.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
looks = query.order_by(Look.is_favourite.desc(), Look.name).all()
|
||||
look_assignments = _count_look_assignments()
|
||||
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments)
|
||||
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/looks/rescan', methods=['POST'])
|
||||
def rescan_looks():
|
||||
@@ -144,8 +152,12 @@ def register_routes(app):
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
||||
new_data['tags'] = {
|
||||
'origin_series': request.form.get('tag_origin_series', '').strip(),
|
||||
'origin_type': request.form.get('tag_origin_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
look.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
look.data = new_data
|
||||
flag_modified(look, 'data')
|
||||
@@ -435,19 +447,32 @@ Character ID: {character_slug}"""
|
||||
def create_look():
|
||||
characters = Character.query.order_by(Character.name).all()
|
||||
loras = get_available_loras('characters')
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
|
||||
filename = f'{look_id}.json'
|
||||
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
|
||||
|
||||
character_id = request.form.get('character_id', '') or None
|
||||
lora_name = request.form.get('lora_lora_name', '')
|
||||
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
|
||||
lora_triggers = request.form.get('lora_lora_triggers', '')
|
||||
positive = request.form.get('positive', '')
|
||||
negative = request.form.get('negative', '')
|
||||
tags = [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()]
|
||||
tags = {
|
||||
'origin_series': request.form.get('tag_origin_series', '').strip(),
|
||||
'origin_type': request.form.get('tag_origin_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
|
||||
form_data = {
|
||||
'name': name, 'character_id': character_id,
|
||||
'lora_lora_name': lora_name, 'lora_lora_weight': lora_weight,
|
||||
'lora_lora_triggers': lora_triggers, 'positive': positive,
|
||||
'negative': negative, 'tags': tags,
|
||||
}
|
||||
|
||||
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
|
||||
filename = f'{look_id}.json'
|
||||
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
|
||||
|
||||
data = {
|
||||
'look_id': look_id,
|
||||
@@ -459,20 +484,26 @@ Character ID: {character_slug}"""
|
||||
'tags': tags
|
||||
}
|
||||
|
||||
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
try:
|
||||
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
|
||||
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
|
||||
character_id=character_id, data=data)
|
||||
db.session.add(new_look)
|
||||
db.session.commit()
|
||||
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
|
||||
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
|
||||
character_id=character_id, data=data,
|
||||
is_nsfw=tags.get('nsfw', False))
|
||||
db.session.add(new_look)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Look "{name}" created!')
|
||||
return redirect(url_for('look_detail', slug=slug))
|
||||
flash(f'Look "{name}" created!')
|
||||
return redirect(url_for('look_detail', slug=slug))
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create look: {e}")
|
||||
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
||||
|
||||
return render_template('looks/create.html', characters=characters, loras=loras)
|
||||
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
||||
|
||||
@app.route('/get_missing_looks')
|
||||
def get_missing_looks():
|
||||
@@ -497,15 +528,16 @@ Character ID: {character_slug}"""
|
||||
return redirect(url_for('looks_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
skipped = 0
|
||||
job_ids = []
|
||||
|
||||
system_prompt = load_prompt('look_system.txt')
|
||||
if not system_prompt:
|
||||
flash('Look system prompt file not found.', 'error')
|
||||
return redirect(url_for('looks_index'))
|
||||
|
||||
looks_dir = app.config['LOOKS_DIR']
|
||||
|
||||
for filename in os.listdir(lora_dir):
|
||||
if not filename.endswith('.safetensors'):
|
||||
continue
|
||||
@@ -515,11 +547,11 @@ Character ID: {character_slug}"""
|
||||
look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
|
||||
json_filename = f"{look_id}.json"
|
||||
json_path = os.path.join(app.config['LOOKS_DIR'], json_filename)
|
||||
json_path = os.path.join(looks_dir, json_filename)
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
@@ -535,54 +567,59 @@ Character ID: {character_slug}"""
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML {html_filename}: {e}")
|
||||
logger.error("Error reading HTML %s: %s", html_filename, e)
|
||||
|
||||
try:
|
||||
print(f"Asking LLM to describe look: {look_name}")
|
||||
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
||||
def make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||
def task_fn(job):
|
||||
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
look_data = json.loads(clean_json)
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
look_data = json.loads(clean_json)
|
||||
|
||||
look_data['look_id'] = look_id
|
||||
look_data['look_name'] = look_name
|
||||
look_data['look_id'] = look_id
|
||||
look_data['look_name'] = look_name
|
||||
|
||||
if 'lora' not in look_data:
|
||||
look_data['lora'] = {}
|
||||
look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if not look_data['lora'].get('lora_triggers'):
|
||||
look_data['lora']['lora_triggers'] = name_base
|
||||
if look_data['lora'].get('lora_weight') is None:
|
||||
look_data['lora']['lora_weight'] = 0.8
|
||||
if look_data['lora'].get('lora_weight_min') is None:
|
||||
look_data['lora']['lora_weight_min'] = 0.7
|
||||
if look_data['lora'].get('lora_weight_max') is None:
|
||||
look_data['lora']['lora_weight_max'] = 1.0
|
||||
if 'lora' not in look_data:
|
||||
look_data['lora'] = {}
|
||||
look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if not look_data['lora'].get('lora_triggers'):
|
||||
look_data['lora']['lora_triggers'] = name_base
|
||||
if look_data['lora'].get('lora_weight') is None:
|
||||
look_data['lora']['lora_weight'] = 0.8
|
||||
if look_data['lora'].get('lora_weight_min') is None:
|
||||
look_data['lora']['lora_weight_min'] = 0.7
|
||||
if look_data['lora'].get('lora_weight_max') is None:
|
||||
look_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(look_data, f, indent=2)
|
||||
os.makedirs(looks_dir, exist_ok=True)
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(look_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': look_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||
return task_fn
|
||||
|
||||
time.sleep(0.5)
|
||||
job = _enqueue_task(f"Create look: {look_name}", make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||
job_ids.append(job['id'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating look for {filename}: {e}")
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_looks()
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync looks DB", sync_task)
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
sync_looks()
|
||||
msg = f'Successfully processed looks: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No looks created or overwritten. {skipped_count} existing entries found.')
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
|
||||
return redirect(url_for('looks_index'))
|
||||
|
||||
return redirect(url_for('looks_index'))
|
||||
@app.route('/look/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_look_favourite(slug):
|
||||
look = Look.query.filter_by(slug=slug).first_or_404()
|
||||
look.is_favourite = not look.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': look.is_favourite}
|
||||
return redirect(url_for('look_detail', slug=slug))
|
||||
Reference in New Issue
Block a user