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:
@@ -18,6 +18,8 @@ def register_routes(app):
|
||||
from routes import strengths
|
||||
from routes import transfer
|
||||
from routes import api
|
||||
from routes import regenerate
|
||||
from routes import search
|
||||
|
||||
queue_api.register_routes(app)
|
||||
settings.register_routes(app)
|
||||
@@ -37,3 +39,5 @@ def register_routes(app):
|
||||
strengths.register_routes(app)
|
||||
transfer.register_routes(app)
|
||||
api.register_routes(app)
|
||||
regenerate.register_routes(app)
|
||||
search.register_routes(app)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
|
||||
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Action, Outfit, Style, Scene, Detailer, Checkpoint, Settings, Look
|
||||
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
|
||||
from services.sync import sync_actions
|
||||
from services.file_io import get_available_loras
|
||||
@@ -38,8 +37,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/actions')
|
||||
def actions_index():
|
||||
actions = Action.query.order_by(Action.name).all()
|
||||
return render_template('actions/index.html', actions=actions)
|
||||
query = Action.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)
|
||||
actions = query.order_by(Action.is_favourite.desc(), Action.name).all()
|
||||
return render_template('actions/index.html', actions=actions, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/actions/rescan', methods=['POST'])
|
||||
def rescan_actions():
|
||||
@@ -118,9 +126,15 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
||||
# Suppress wardrobe toggle
|
||||
new_data['suppress_wardrobe'] = request.form.get('suppress_wardrobe') == 'on'
|
||||
|
||||
# Update Tags (structured dict)
|
||||
new_data['tags'] = {
|
||||
'participants': request.form.get('tag_participants', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
action.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
action.data = new_data
|
||||
flag_modified(action, "data")
|
||||
@@ -201,6 +215,12 @@ def register_routes(app):
|
||||
session[f'extra_neg_action_{slug}'] = extra_negative
|
||||
session.modified = True
|
||||
|
||||
suppress_wardrobe = action_obj.data.get('suppress_wardrobe', False)
|
||||
|
||||
# Strip any wardrobe fields from manual selection when suppressed
|
||||
if suppress_wardrobe:
|
||||
selected_fields = [f for f in selected_fields if not f.startswith('wardrobe::')]
|
||||
|
||||
# Build combined data for prompt building
|
||||
if character:
|
||||
# Combine character identity/wardrobe with action details
|
||||
@@ -232,16 +252,13 @@ def register_routes(app):
|
||||
if 'lora' not in combined_data: combined_data['lora'] = {}
|
||||
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}"
|
||||
|
||||
# Merge tags
|
||||
combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', [])))
|
||||
|
||||
# Use action's defaults if no manual selection
|
||||
if not selected_fields:
|
||||
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
|
||||
|
||||
# Auto-include essential character fields if a character is selected
|
||||
if selected_fields:
|
||||
_ensure_character_fields(character, selected_fields)
|
||||
_ensure_character_fields(character, selected_fields, include_wardrobe=not suppress_wardrobe)
|
||||
else:
|
||||
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
|
||||
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
|
||||
@@ -249,12 +266,13 @@ def register_routes(app):
|
||||
for key in ['base', 'head']:
|
||||
if character.data.get('identity', {}).get(key):
|
||||
selected_fields.append(f'identity::{key}')
|
||||
# Add wardrobe fields
|
||||
from utils import _WARDROBE_KEYS
|
||||
wardrobe = character.get_active_wardrobe()
|
||||
for key in _WARDROBE_KEYS:
|
||||
if wardrobe.get(key):
|
||||
selected_fields.append(f'wardrobe::{key}')
|
||||
# Add wardrobe fields (unless suppressed)
|
||||
if not suppress_wardrobe:
|
||||
from utils import _WARDROBE_KEYS
|
||||
wardrobe = character.get_active_wardrobe()
|
||||
for key in _WARDROBE_KEYS:
|
||||
if wardrobe.get(key):
|
||||
selected_fields.append(f'wardrobe::{key}')
|
||||
|
||||
default_fields = action_obj.default_fields
|
||||
active_outfit = character.active_outfit
|
||||
@@ -281,7 +299,7 @@ def register_routes(app):
|
||||
'tags': action_obj.data.get('tags', [])
|
||||
}
|
||||
if not selected_fields:
|
||||
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags']
|
||||
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers']
|
||||
default_fields = action_obj.default_fields
|
||||
active_outfit = 'default'
|
||||
|
||||
@@ -322,13 +340,14 @@ def register_routes(app):
|
||||
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
|
||||
extra_parts.append(val)
|
||||
|
||||
# Wardrobe (active outfit)
|
||||
from utils import _WARDROBE_KEYS
|
||||
wardrobe = extra_char.get_active_wardrobe()
|
||||
for key in _WARDROBE_KEYS:
|
||||
val = wardrobe.get(key)
|
||||
if val:
|
||||
extra_parts.append(val)
|
||||
# Wardrobe (active outfit) — skip if suppressed
|
||||
if not suppress_wardrobe:
|
||||
from utils import _WARDROBE_KEYS
|
||||
wardrobe = extra_char.get_active_wardrobe()
|
||||
for key in _WARDROBE_KEYS:
|
||||
val = wardrobe.get(key)
|
||||
if val:
|
||||
extra_parts.append(val)
|
||||
|
||||
# Append to main prompt
|
||||
if extra_parts:
|
||||
@@ -391,72 +410,76 @@ def register_routes(app):
|
||||
actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(actions_lora_dir)
|
||||
if not os.path.exists(actions_lora_dir):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Actions LoRA directory not found.'}, 400
|
||||
flash('Actions LoRA directory not found.', 'error')
|
||||
return redirect(url_for('actions_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
|
||||
system_prompt = load_prompt('action_system.txt')
|
||||
if not system_prompt:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Action system prompt file not found.'}, 500
|
||||
flash('Action system prompt file not found.', 'error')
|
||||
return redirect(url_for('actions_index'))
|
||||
|
||||
for filename in os.listdir(actions_lora_dir):
|
||||
if filename.endswith('.safetensors'):
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
||||
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
job_ids = []
|
||||
skipped = 0
|
||||
|
||||
json_filename = f"{action_id}.json"
|
||||
json_path = os.path.join(app.config['ACTIONS_DIR'], json_filename)
|
||||
for filename in sorted(os.listdir(actions_lora_dir)):
|
||||
if not filename.endswith('.safetensors'):
|
||||
continue
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
continue
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
||||
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
html_path = os.path.join(actions_lora_dir, html_filename)
|
||||
html_content = ""
|
||||
if os.path.exists(html_path):
|
||||
try:
|
||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||
html_raw = hf.read()
|
||||
# Strip HTML tags but keep text content for LLM context
|
||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
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}")
|
||||
json_filename = f"{action_id}.json"
|
||||
json_path = os.path.join(app.config['ACTIONS_DIR'], json_filename)
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Read HTML companion file if it exists
|
||||
html_path = os.path.join(actions_lora_dir, f"{name_base}.html")
|
||||
html_content = ""
|
||||
if os.path.exists(html_path):
|
||||
try:
|
||||
print(f"Asking LLM to describe action: {action_name}")
|
||||
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||
html_raw = hf.read()
|
||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
def make_task(fn, aid, aname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||
def task_fn(job):
|
||||
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{fn}'"
|
||||
if html_ctx:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
|
||||
|
||||
# Clean response
|
||||
llm_response = call_llm(prompt, sys_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
action_data = json.loads(clean_json)
|
||||
|
||||
# Enforce system values while preserving LLM-extracted metadata
|
||||
action_data['action_id'] = action_id
|
||||
action_data['action_name'] = action_name
|
||||
action_data['action_id'] = aid
|
||||
action_data['action_name'] = aname
|
||||
|
||||
# Update lora dict safely
|
||||
if 'lora' not in action_data: action_data['lora'] = {}
|
||||
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if 'lora' not in action_data:
|
||||
action_data['lora'] = {}
|
||||
action_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||
|
||||
# Fallbacks if LLM failed to extract metadata
|
||||
if not action_data['lora'].get('lora_triggers'):
|
||||
action_data['lora']['lora_triggers'] = name_base
|
||||
action_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||
if action_data['lora'].get('lora_weight') is None:
|
||||
action_data['lora']['lora_weight'] = 1.0
|
||||
if action_data['lora'].get('lora_weight_min') is None:
|
||||
@@ -464,39 +487,45 @@ def register_routes(app):
|
||||
if action_data['lora'].get('lora_weight_max') is None:
|
||||
action_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
with open(json_path, 'w') as f:
|
||||
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||
with open(jp, 'w') as f:
|
||||
json.dump(action_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': aname, 'action': 'overwritten' if is_exist else 'created'}
|
||||
return task_fn
|
||||
|
||||
# Small delay to avoid API rate limits if many files
|
||||
time.sleep(0.5)
|
||||
job = _enqueue_task(
|
||||
f"Create action: {action_name}",
|
||||
make_task(filename, action_id, action_name, json_path,
|
||||
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||
)
|
||||
job_ids.append(job['id'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating action for {filename}: {e}")
|
||||
# Enqueue a sync task to run after all creates
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_actions()
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync actions DB", sync_task)
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
sync_actions()
|
||||
msg = f'Successfully processed actions: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No actions created or overwritten. {skipped_count} existing actions found.')
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
|
||||
flash(f'Queued {len(job_ids)} action creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||
return redirect(url_for('actions_index'))
|
||||
|
||||
@app.route('/action/create', methods=['GET', 'POST'])
|
||||
def create_action():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
prompt = request.form.get('prompt', '')
|
||||
use_llm = request.form.get('use_llm') == 'on'
|
||||
|
||||
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
@@ -513,12 +542,12 @@ def register_routes(app):
|
||||
if use_llm:
|
||||
if not prompt:
|
||||
flash("Description is required when AI generation is enabled.")
|
||||
return redirect(request.url)
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
|
||||
system_prompt = load_prompt('action_system.txt')
|
||||
if not system_prompt:
|
||||
flash("Action system prompt file not found.")
|
||||
return redirect(request.url)
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
|
||||
try:
|
||||
llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt)
|
||||
@@ -529,7 +558,7 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"LLM error: {e}")
|
||||
flash(f"Failed to generate action profile: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
else:
|
||||
action_data = {
|
||||
"action_id": safe_slug,
|
||||
@@ -538,6 +567,7 @@ def register_routes(app):
|
||||
"base": "", "head": "", "upper_body": "", "lower_body": "",
|
||||
"hands": "", "feet": "", "additional": ""
|
||||
},
|
||||
"suppress_wardrobe": False,
|
||||
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
|
||||
"tags": []
|
||||
}
|
||||
@@ -559,9 +589,9 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create action: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
|
||||
return render_template('actions/create.html')
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/action/<path:slug>/clone', methods=['POST'])
|
||||
def clone_action(slug):
|
||||
@@ -619,3 +649,12 @@ def register_routes(app):
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/action/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_action_favourite(slug):
|
||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||
action.is_favourite = not action.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': action.is_favourite}
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
@@ -10,7 +10,7 @@ from werkzeug.utils import secure_filename
|
||||
from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
|
||||
from services.file_io import get_available_loras
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.llm import call_llm, load_prompt
|
||||
from services.llm import call_character_mcp_tool, call_llm, load_prompt
|
||||
from services.prompts import build_prompt
|
||||
from services.sync import sync_characters
|
||||
from services.workflow import _get_default_checkpoint, _prepare_workflow
|
||||
@@ -23,8 +23,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
characters = Character.query.order_by(Character.name).all()
|
||||
return render_template('index.html', characters=characters)
|
||||
query = Character.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)
|
||||
characters = query.order_by(Character.is_favourite.desc(), Character.name).all()
|
||||
return render_template('index.html', characters=characters, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/rescan', methods=['POST'])
|
||||
def rescan():
|
||||
@@ -219,6 +228,7 @@ def register_routes(app):
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
prompt = request.form.get('prompt', '')
|
||||
wiki_url = request.form.get('wiki_url', '').strip()
|
||||
use_llm = request.form.get('use_llm') == 'on'
|
||||
outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none'
|
||||
existing_outfit_id = request.form.get('existing_outfit_id')
|
||||
@@ -228,6 +238,7 @@ def register_routes(app):
|
||||
'name': name,
|
||||
'filename': slug,
|
||||
'prompt': prompt,
|
||||
'wiki_url': wiki_url,
|
||||
'use_llm': use_llm,
|
||||
'outfit_mode': outfit_mode,
|
||||
'existing_outfit_id': existing_outfit_id
|
||||
@@ -261,6 +272,20 @@ def register_routes(app):
|
||||
flash(error_msg)
|
||||
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
||||
|
||||
# Fetch reference data from wiki URL if provided
|
||||
wiki_reference = ''
|
||||
if wiki_url:
|
||||
logger.info(f"Fetching character data from URL: {wiki_url}")
|
||||
wiki_data = call_character_mcp_tool('get_character_from_url', {
|
||||
'url': wiki_url,
|
||||
'name': name,
|
||||
})
|
||||
if wiki_data:
|
||||
wiki_reference = f"\n\nReference data from wiki:\n{wiki_data}\n\nUse this reference to accurately describe the character's appearance, outfit, and features."
|
||||
logger.info(f"Got wiki reference data ({len(wiki_data)} chars)")
|
||||
else:
|
||||
logger.warning(f"Failed to fetch wiki data from {wiki_url}")
|
||||
|
||||
# Step 1: Generate or select outfit first
|
||||
default_outfit_id = 'default'
|
||||
generated_outfit = None
|
||||
@@ -271,7 +296,7 @@ def register_routes(app):
|
||||
outfit_name = f"{name} - default"
|
||||
|
||||
outfit_prompt = f"""Generate an outfit for character "{name}".
|
||||
The character is described as: {prompt}
|
||||
The character is described as: {prompt}{wiki_reference}
|
||||
|
||||
Create an outfit JSON with wardrobe fields appropriate for this character."""
|
||||
|
||||
@@ -344,7 +369,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
|
||||
|
||||
# Step 2: Generate character (without wardrobe section)
|
||||
char_prompt = f"""Generate a character named "{name}".
|
||||
Description: {prompt}
|
||||
Description: {prompt}{wiki_reference}
|
||||
|
||||
Default Outfit: {default_outfit_id}
|
||||
|
||||
@@ -516,9 +541,13 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
||||
if form_key in request.form:
|
||||
new_data['wardrobe'][key] = request.form.get(form_key)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
||||
# Update structured tags
|
||||
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,
|
||||
}
|
||||
character.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
character.data = new_data
|
||||
flag_modified(character, "data")
|
||||
@@ -867,3 +896,12 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
||||
db.session.commit()
|
||||
flash('Default prompt selection saved for this character!')
|
||||
return redirect(url_for('detail', slug=slug))
|
||||
|
||||
@app.route('/character/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_character_favourite(slug):
|
||||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||||
character.is_favourite = not character.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': character.is_favourite}
|
||||
return redirect(url_for('detail', slug=slug))
|
||||
|
||||
@@ -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, Checkpoint, Character, Settings
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts
|
||||
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
|
||||
from services.sync import sync_checkpoints, _default_checkpoint_data
|
||||
from services.file_io import get_available_checkpoints
|
||||
@@ -57,8 +56,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/checkpoints')
|
||||
def checkpoints_index():
|
||||
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
|
||||
return render_template('checkpoints/index.html', checkpoints=checkpoints)
|
||||
query = Checkpoint.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)
|
||||
checkpoints = query.order_by(Checkpoint.is_favourite.desc(), Checkpoint.name).all()
|
||||
return render_template('checkpoints/index.html', checkpoints=checkpoints, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/checkpoints/rescan', methods=['POST'])
|
||||
def rescan_checkpoints():
|
||||
@@ -189,9 +197,9 @@ def register_routes(app):
|
||||
os.makedirs(checkpoints_dir, exist_ok=True)
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
skipped = 0
|
||||
written_directly = 0
|
||||
job_ids = []
|
||||
|
||||
system_prompt = load_prompt('checkpoint_system.txt')
|
||||
if not system_prompt:
|
||||
@@ -219,7 +227,7 @@ def register_routes(app):
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Look for a matching HTML file alongside the model file
|
||||
@@ -235,52 +243,72 @@ def register_routes(app):
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML for {filename}: {e}")
|
||||
logger.error("Error reading HTML for %s: %s", filename, e)
|
||||
|
||||
defaults = _default_checkpoint_data(checkpoint_path, filename)
|
||||
|
||||
if html_content:
|
||||
try:
|
||||
print(f"Asking LLM to describe checkpoint: {filename}")
|
||||
prompt = (
|
||||
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
|
||||
f"(checkpoint_path: '{checkpoint_path}').\n\n"
|
||||
f"Here 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()
|
||||
ckpt_data = json.loads(clean_json)
|
||||
# Enforce fixed fields
|
||||
ckpt_data['checkpoint_path'] = checkpoint_path
|
||||
ckpt_data['checkpoint_name'] = filename
|
||||
# Fill missing fields with defaults
|
||||
for key, val in defaults.items():
|
||||
if key not in ckpt_data or ckpt_data[key] is None:
|
||||
ckpt_data[key] = val
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"LLM error for {filename}: {e}. Using defaults.")
|
||||
ckpt_data = defaults
|
||||
# Has HTML companion — enqueue LLM task
|
||||
def make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing):
|
||||
def task_fn(job):
|
||||
prompt = (
|
||||
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
|
||||
f"(checkpoint_path: '{checkpoint_path}').\n\n"
|
||||
f"Here is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
||||
)
|
||||
try:
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
ckpt_data = json.loads(clean_json)
|
||||
ckpt_data['checkpoint_path'] = checkpoint_path
|
||||
ckpt_data['checkpoint_name'] = filename
|
||||
for key, val in defaults.items():
|
||||
if key not in ckpt_data or ckpt_data[key] is None:
|
||||
ckpt_data[key] = val
|
||||
except Exception as e:
|
||||
logger.error("LLM error for %s: %s. Using defaults.", filename, e)
|
||||
ckpt_data = defaults
|
||||
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(ckpt_data, f, indent=2)
|
||||
|
||||
job['result'] = {'name': filename, 'action': 'overwritten' if is_existing else 'created'}
|
||||
return task_fn
|
||||
|
||||
job = _enqueue_task(f"Create checkpoint: {filename}", make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing))
|
||||
job_ids.append(job['id'])
|
||||
else:
|
||||
ckpt_data = defaults
|
||||
# No HTML — write defaults directly (no LLM needed)
|
||||
try:
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(defaults, f, indent=2)
|
||||
written_directly += 1
|
||||
except Exception as e:
|
||||
logger.error("Error saving JSON for %s: %s", filename, e)
|
||||
|
||||
try:
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(ckpt_data, f, indent=2)
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
print(f"Error saving JSON for {filename}: {e}")
|
||||
needs_sync = len(job_ids) > 0 or written_directly > 0
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
sync_checkpoints()
|
||||
msg = f'Successfully processed checkpoints: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No checkpoints created or overwritten. {skipped_count} existing entries found.')
|
||||
if needs_sync:
|
||||
if job_ids:
|
||||
# Sync after all LLM tasks complete
|
||||
def sync_task(job):
|
||||
sync_checkpoints()
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync checkpoints DB", sync_task)
|
||||
else:
|
||||
# No LLM tasks — sync immediately
|
||||
sync_checkpoints()
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'written_directly': written_directly, 'skipped': skipped}
|
||||
flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).')
|
||||
return redirect(url_for('checkpoints_index'))
|
||||
|
||||
@app.route('/checkpoint/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_checkpoint_favourite(slug):
|
||||
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
|
||||
ckpt.is_favourite = not ckpt.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': ckpt.is_favourite}
|
||||
return redirect(url_for('checkpoint_detail', slug=slug))
|
||||
|
||||
@@ -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, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look
|
||||
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
|
||||
from services.sync import sync_detailers
|
||||
from services.file_io import get_available_loras
|
||||
@@ -27,11 +26,8 @@ def register_routes(app):
|
||||
combined_data = character.data.copy()
|
||||
combined_data['character_id'] = character.character_id
|
||||
|
||||
# Merge detailer prompt into character's tags
|
||||
# Capture detailer prompt for injection into main prompt later
|
||||
detailer_prompt = detailer_obj.data.get('prompt', '')
|
||||
if detailer_prompt:
|
||||
if 'tags' not in combined_data: combined_data['tags'] = []
|
||||
combined_data['tags'].append(detailer_prompt)
|
||||
|
||||
# Merge detailer lora triggers if present
|
||||
detailer_lora = detailer_obj.data.get('lora', {})
|
||||
@@ -53,21 +49,19 @@ def register_routes(app):
|
||||
for key in _WARDROBE_KEYS:
|
||||
if wardrobe.get(key):
|
||||
selected_fields.append(f'wardrobe::{key}')
|
||||
selected_fields.extend(['special::tags', 'lora::lora_triggers'])
|
||||
selected_fields.extend(['lora::lora_triggers'])
|
||||
|
||||
default_fields = detailer_obj.default_fields
|
||||
active_outfit = character.active_outfit
|
||||
else:
|
||||
# Detailer only - no character
|
||||
detailer_prompt = detailer_obj.data.get('prompt', '')
|
||||
detailer_tags = [detailer_prompt] if detailer_prompt else []
|
||||
combined_data = {
|
||||
'character_id': detailer_obj.detailer_id,
|
||||
'tags': detailer_tags,
|
||||
'lora': detailer_obj.data.get('lora', {}),
|
||||
}
|
||||
if not selected_fields:
|
||||
selected_fields = ['special::tags', 'lora::lora_triggers']
|
||||
selected_fields = ['lora::lora_triggers']
|
||||
default_fields = detailer_obj.default_fields
|
||||
active_outfit = 'default'
|
||||
|
||||
@@ -76,6 +70,11 @@ def register_routes(app):
|
||||
|
||||
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
|
||||
|
||||
# Inject detailer prompt directly into main prompt
|
||||
if detailer_prompt:
|
||||
prompt_str = detailer_prompt if isinstance(detailer_prompt, str) else ', '.join(detailer_prompt)
|
||||
prompts['main'] = f"{prompts['main']}, {prompt_str}" if prompts['main'] else prompt_str
|
||||
|
||||
_append_background(prompts, character)
|
||||
|
||||
if extra_positive:
|
||||
@@ -87,8 +86,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/detailers')
|
||||
def detailers_index():
|
||||
detailers = Detailer.query.order_by(Detailer.name).all()
|
||||
return render_template('detailers/index.html', detailers=detailers)
|
||||
query = Detailer.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)
|
||||
detailers = query.order_by(Detailer.is_favourite.desc(), Detailer.name).all()
|
||||
return render_template('detailers/index.html', detailers=detailers, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/detailers/rescan', methods=['POST'])
|
||||
def rescan_detailers():
|
||||
@@ -162,9 +170,13 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
||||
# Update structured tags
|
||||
new_data['tags'] = {
|
||||
'associated_resource': request.form.get('tag_associated_resource', '').strip(),
|
||||
'adetailer_targets': request.form.getlist('tag_adetailer_targets'),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
detailer.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
detailer.data = new_data
|
||||
flag_modified(detailer, "data")
|
||||
@@ -318,15 +330,16 @@ def register_routes(app):
|
||||
return redirect(url_for('detailers_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
skipped = 0
|
||||
job_ids = []
|
||||
|
||||
system_prompt = load_prompt('detailer_system.txt')
|
||||
if not system_prompt:
|
||||
flash('Detailer system prompt file not found.', 'error')
|
||||
return redirect(url_for('detailers_index'))
|
||||
|
||||
detailers_dir = app.config['DETAILERS_DIR']
|
||||
|
||||
for filename in os.listdir(detailers_lora_dir):
|
||||
if filename.endswith('.safetensors'):
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
@@ -334,11 +347,11 @@ def register_routes(app):
|
||||
detailer_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
|
||||
json_filename = f"{detailer_id}.json"
|
||||
json_path = os.path.join(app.config['DETAILERS_DIR'], json_filename)
|
||||
json_path = os.path.join(detailers_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"
|
||||
@@ -354,63 +367,63 @@ def register_routes(app):
|
||||
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 detailer: {detailer_name}")
|
||||
prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
def make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||
def task_fn(job):
|
||||
prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
detailer_data = json.loads(clean_json)
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
detailer_data = json.loads(clean_json)
|
||||
|
||||
detailer_data['detailer_id'] = detailer_id
|
||||
detailer_data['detailer_name'] = detailer_name
|
||||
detailer_data['detailer_id'] = detailer_id
|
||||
detailer_data['detailer_name'] = detailer_name
|
||||
|
||||
if 'lora' not in detailer_data: detailer_data['lora'] = {}
|
||||
detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if 'lora' not in detailer_data: detailer_data['lora'] = {}
|
||||
detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
|
||||
if not detailer_data['lora'].get('lora_triggers'):
|
||||
detailer_data['lora']['lora_triggers'] = name_base
|
||||
if detailer_data['lora'].get('lora_weight') is None:
|
||||
detailer_data['lora']['lora_weight'] = 1.0
|
||||
if detailer_data['lora'].get('lora_weight_min') is None:
|
||||
detailer_data['lora']['lora_weight_min'] = 0.7
|
||||
if detailer_data['lora'].get('lora_weight_max') is None:
|
||||
detailer_data['lora']['lora_weight_max'] = 1.0
|
||||
if not detailer_data['lora'].get('lora_triggers'):
|
||||
detailer_data['lora']['lora_triggers'] = name_base
|
||||
if detailer_data['lora'].get('lora_weight') is None:
|
||||
detailer_data['lora']['lora_weight'] = 1.0
|
||||
if detailer_data['lora'].get('lora_weight_min') is None:
|
||||
detailer_data['lora']['lora_weight_min'] = 0.7
|
||||
if detailer_data['lora'].get('lora_weight_max') is None:
|
||||
detailer_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(detailer_data, f, indent=2)
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(detailer_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': detailer_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||
return task_fn
|
||||
|
||||
# Small delay to avoid API rate limits if many files
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"Error creating detailer for {filename}: {e}")
|
||||
job = _enqueue_task(f"Create detailer: {detailer_name}", make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||
job_ids.append(job['id'])
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
sync_detailers()
|
||||
msg = f'Successfully processed detailers: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No new detailers created or overwritten. {skipped_count} existing detailers found.')
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_detailers()
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync detailers DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
flash(f'Queued {len(job_ids)} detailer tasks ({skipped} skipped).')
|
||||
return redirect(url_for('detailers_index'))
|
||||
|
||||
@app.route('/detailer/create', methods=['GET', 'POST'])
|
||||
def create_detailer():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
|
||||
form_data = {'name': name, 'filename': slug}
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
@@ -452,6 +465,15 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create detailer: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('detailers/create.html', form_data=form_data)
|
||||
|
||||
return render_template('detailers/create.html')
|
||||
return render_template('detailers/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/detailer/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_detailer_favourite(slug):
|
||||
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
|
||||
detailer.is_favourite = not detailer.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': detailer.is_favourite}
|
||||
return redirect(url_for('detailer_detail', slug=slug))
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
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))
|
||||
@@ -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, Outfit, Action, Style, Scene, Detailer, Checkpoint, Settings, Look
|
||||
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
|
||||
from services.sync import sync_outfits
|
||||
from services.file_io import get_available_loras, _count_outfit_lora_assignments
|
||||
@@ -37,9 +36,18 @@ def register_routes(app):
|
||||
|
||||
@app.route('/outfits')
|
||||
def outfits_index():
|
||||
outfits = Outfit.query.order_by(Outfit.name).all()
|
||||
query = Outfit.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)
|
||||
outfits = query.order_by(Outfit.is_favourite.desc(), Outfit.name).all()
|
||||
lora_assignments = _count_outfit_lora_assignments()
|
||||
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments)
|
||||
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/outfits/rescan', methods=['POST'])
|
||||
def rescan_outfits():
|
||||
@@ -53,20 +61,24 @@ def register_routes(app):
|
||||
clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(clothing_lora_dir)
|
||||
if not os.path.exists(clothing_lora_dir):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Clothing LoRA directory not found.'}, 400
|
||||
flash('Clothing LoRA directory not found.', 'error')
|
||||
return redirect(url_for('outfits_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
|
||||
system_prompt = load_prompt('outfit_system.txt')
|
||||
if not system_prompt:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Outfit system prompt file not found.'}, 500
|
||||
flash('Outfit system prompt file not found.', 'error')
|
||||
return redirect(url_for('outfits_index'))
|
||||
|
||||
for filename in os.listdir(clothing_lora_dir):
|
||||
job_ids = []
|
||||
skipped = 0
|
||||
|
||||
for filename in sorted(os.listdir(clothing_lora_dir)):
|
||||
if not filename.endswith('.safetensors'):
|
||||
continue
|
||||
|
||||
@@ -79,11 +91,11 @@ def register_routes(app):
|
||||
|
||||
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"
|
||||
html_path = os.path.join(clothing_lora_dir, html_filename)
|
||||
# Read HTML companion file if it exists
|
||||
html_path = os.path.join(clothing_lora_dir, f"{name_base}.html")
|
||||
html_content = ""
|
||||
if os.path.exists(html_path):
|
||||
try:
|
||||
@@ -94,57 +106,59 @@ def register_routes(app):
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
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}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
print(f"Asking LLM to describe outfit: {outfit_name}")
|
||||
prompt = f"Create an outfit profile for a clothing 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(fn, oid, oname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||
def task_fn(job):
|
||||
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{fn}'"
|
||||
if html_ctx:
|
||||
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_ctx[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
outfit_data = json.loads(clean_json)
|
||||
llm_response = call_llm(prompt, sys_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
outfit_data = json.loads(clean_json)
|
||||
|
||||
outfit_data['outfit_id'] = outfit_id
|
||||
outfit_data['outfit_name'] = outfit_name
|
||||
outfit_data['outfit_id'] = oid
|
||||
outfit_data['outfit_name'] = oname
|
||||
|
||||
if 'lora' not in outfit_data:
|
||||
outfit_data['lora'] = {}
|
||||
outfit_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if not outfit_data['lora'].get('lora_triggers'):
|
||||
outfit_data['lora']['lora_triggers'] = name_base
|
||||
if outfit_data['lora'].get('lora_weight') is None:
|
||||
outfit_data['lora']['lora_weight'] = 0.8
|
||||
if outfit_data['lora'].get('lora_weight_min') is None:
|
||||
outfit_data['lora']['lora_weight_min'] = 0.7
|
||||
if outfit_data['lora'].get('lora_weight_max') is None:
|
||||
outfit_data['lora']['lora_weight_max'] = 1.0
|
||||
if 'lora' not in outfit_data:
|
||||
outfit_data['lora'] = {}
|
||||
outfit_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||
if not outfit_data['lora'].get('lora_triggers'):
|
||||
outfit_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||
if outfit_data['lora'].get('lora_weight') is None:
|
||||
outfit_data['lora']['lora_weight'] = 0.8
|
||||
if outfit_data['lora'].get('lora_weight_min') is None:
|
||||
outfit_data['lora']['lora_weight_min'] = 0.7
|
||||
if outfit_data['lora'].get('lora_weight_max') is None:
|
||||
outfit_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
os.makedirs(app.config['CLOTHING_DIR'], exist_ok=True)
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(outfit_data, f, indent=2)
|
||||
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||
with open(jp, 'w') as f:
|
||||
json.dump(outfit_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': oname, 'action': 'overwritten' if is_exist else 'created'}
|
||||
return task_fn
|
||||
|
||||
time.sleep(0.5)
|
||||
job = _enqueue_task(
|
||||
f"Create outfit: {outfit_name}",
|
||||
make_task(filename, outfit_id, outfit_name, json_path,
|
||||
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||
)
|
||||
job_ids.append(job['id'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating outfit for {filename}: {e}")
|
||||
# Enqueue a sync task to run after all creates
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_outfits()
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync outfits DB", sync_task)
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
sync_outfits()
|
||||
msg = f'Successfully processed outfits: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No outfits 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)} outfit creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||
return redirect(url_for('outfits_index'))
|
||||
|
||||
def _get_linked_characters_for_outfit(outfit):
|
||||
@@ -232,9 +246,12 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
||||
# Update Tags (structured dict)
|
||||
new_data['tags'] = {
|
||||
'outfit_type': request.form.get('tag_outfit_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
outfit.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
outfit.data = new_data
|
||||
flag_modified(outfit, "data")
|
||||
@@ -409,12 +426,16 @@ def register_routes(app):
|
||||
|
||||
@app.route('/outfit/create', methods=['GET', 'POST'])
|
||||
def create_outfit():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
prompt = request.form.get('prompt', '')
|
||||
use_llm = request.form.get('use_llm') == 'on'
|
||||
|
||||
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
|
||||
|
||||
# Auto-generate slug from name if not provided
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
@@ -435,13 +456,13 @@ def register_routes(app):
|
||||
if use_llm:
|
||||
if not prompt:
|
||||
flash("Description is required when AI generation is enabled.")
|
||||
return redirect(request.url)
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
|
||||
# Generate JSON with LLM
|
||||
system_prompt = load_prompt('outfit_system.txt')
|
||||
if not system_prompt:
|
||||
flash("System prompt file not found.")
|
||||
return redirect(request.url)
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
|
||||
try:
|
||||
llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt)
|
||||
@@ -477,7 +498,7 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"LLM error: {e}")
|
||||
flash(f"Failed to generate outfit profile: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
else:
|
||||
# Create blank outfit template
|
||||
outfit_data = {
|
||||
@@ -523,9 +544,9 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create outfit: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
|
||||
return render_template('outfits/create.html')
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/outfit/<path:slug>/save_defaults', methods=['POST'])
|
||||
def save_outfit_defaults(slug):
|
||||
@@ -601,3 +622,12 @@ def register_routes(app):
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/outfit/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_outfit_favourite(slug):
|
||||
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
|
||||
outfit.is_favourite = not outfit.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': outfit.is_favourite}
|
||||
return redirect(url_for('outfit_detail', slug=slug))
|
||||
|
||||
@@ -149,6 +149,8 @@ def register_routes(app):
|
||||
'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')),
|
||||
@@ -247,11 +249,15 @@ def register_routes(app):
|
||||
|
||||
@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
|
||||
@@ -265,7 +271,7 @@ def register_routes(app):
|
||||
system_prompt = load_prompt('preset_system.txt')
|
||||
if not system_prompt:
|
||||
flash('Preset system prompt file not found.', 'error')
|
||||
return redirect(request.url)
|
||||
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}",
|
||||
@@ -276,7 +282,7 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
logger.exception("LLM error creating preset: %s", e)
|
||||
flash(f"AI generation failed: {e}", 'error')
|
||||
return redirect(request.url)
|
||||
return render_template('presets/create.html', form_data=form_data)
|
||||
else:
|
||||
preset_data = {
|
||||
'character': {'character_id': 'random', 'use_lora': True,
|
||||
@@ -314,7 +320,7 @@ def register_routes(app):
|
||||
flash(f"Preset '{name}' created!")
|
||||
return redirect(url_for('edit_preset', slug=safe_slug))
|
||||
|
||||
return render_template('presets/create.html')
|
||||
return render_template('presets/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/get_missing_presets')
|
||||
def get_missing_presets():
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import logging
|
||||
from services.job_queue import (
|
||||
_job_queue_lock, _job_queue, _job_history, _queue_worker_event,
|
||||
_job_queue_lock, _job_queue, _llm_queue, _job_history,
|
||||
_queue_worker_event, _llm_worker_event,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
# Both queues for iteration
|
||||
_ALL_QUEUES = (_job_queue, _llm_queue)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
@@ -12,23 +16,27 @@ def register_routes(app):
|
||||
def api_queue_list():
|
||||
"""Return the current queue as JSON."""
|
||||
with _job_queue_lock:
|
||||
jobs = [
|
||||
{
|
||||
'id': j['id'],
|
||||
'label': j['label'],
|
||||
'status': j['status'],
|
||||
'error': j['error'],
|
||||
'created_at': j['created_at'],
|
||||
}
|
||||
for j in _job_queue
|
||||
]
|
||||
jobs = []
|
||||
for q in _ALL_QUEUES:
|
||||
for j in q:
|
||||
jobs.append({
|
||||
'id': j['id'],
|
||||
'label': j['label'],
|
||||
'status': j['status'],
|
||||
'error': j['error'],
|
||||
'created_at': j['created_at'],
|
||||
'job_type': j.get('job_type', 'comfyui'),
|
||||
})
|
||||
return {'jobs': jobs, 'count': len(jobs)}
|
||||
|
||||
@app.route('/api/queue/count')
|
||||
def api_queue_count():
|
||||
"""Return just the count of active (non-done, non-failed) jobs."""
|
||||
with _job_queue_lock:
|
||||
count = sum(1 for j in _job_queue if j['status'] in ('pending', 'processing', 'paused'))
|
||||
count = sum(
|
||||
1 for q in _ALL_QUEUES for j in q
|
||||
if j['status'] in ('pending', 'processing', 'paused')
|
||||
)
|
||||
return {'count': count}
|
||||
|
||||
@app.route('/api/queue/<job_id>/remove', methods=['POST'])
|
||||
@@ -40,10 +48,12 @@ def register_routes(app):
|
||||
return {'error': 'Job not found'}, 404
|
||||
if job['status'] == 'processing':
|
||||
return {'error': 'Cannot remove a job that is currently processing'}, 400
|
||||
try:
|
||||
_job_queue.remove(job)
|
||||
except ValueError:
|
||||
pass # Already not in queue
|
||||
for q in _ALL_QUEUES:
|
||||
try:
|
||||
q.remove(job)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
job['status'] = 'removed'
|
||||
return {'status': 'ok'}
|
||||
|
||||
@@ -58,24 +68,29 @@ def register_routes(app):
|
||||
job['status'] = 'paused'
|
||||
elif job['status'] == 'paused':
|
||||
job['status'] = 'pending'
|
||||
_queue_worker_event.set()
|
||||
# Signal the appropriate worker
|
||||
if job.get('job_type') == 'llm':
|
||||
_llm_worker_event.set()
|
||||
else:
|
||||
_queue_worker_event.set()
|
||||
else:
|
||||
return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400
|
||||
return {'status': 'ok', 'new_status': job['status']}
|
||||
|
||||
@app.route('/api/queue/clear', methods=['POST'])
|
||||
def api_queue_clear():
|
||||
"""Clear all pending jobs from the queue (allows current processing job to finish)."""
|
||||
"""Clear all pending jobs from the queue (allows current processing jobs to finish)."""
|
||||
removed_count = 0
|
||||
with _job_queue_lock:
|
||||
pending_jobs = [j for j in _job_queue if j['status'] == 'pending']
|
||||
for job in pending_jobs:
|
||||
try:
|
||||
_job_queue.remove(job)
|
||||
job['status'] = 'removed'
|
||||
removed_count += 1
|
||||
except ValueError:
|
||||
pass
|
||||
for q in _ALL_QUEUES:
|
||||
pending_jobs = [j for j in q if j['status'] == 'pending']
|
||||
for job in pending_jobs:
|
||||
try:
|
||||
q.remove(job)
|
||||
job['status'] = 'removed'
|
||||
removed_count += 1
|
||||
except ValueError:
|
||||
pass
|
||||
logger.info("Cleared %d pending jobs from queue", removed_count)
|
||||
return {'status': 'ok', 'removed_count': removed_count}
|
||||
|
||||
@@ -91,7 +106,8 @@ def register_routes(app):
|
||||
'label': job['label'],
|
||||
'status': job['status'],
|
||||
'error': job['error'],
|
||||
'comfy_prompt_id': job['comfy_prompt_id'],
|
||||
'job_type': job.get('job_type', 'comfyui'),
|
||||
'comfy_prompt_id': job.get('comfy_prompt_id'),
|
||||
}
|
||||
if job.get('result'):
|
||||
resp['result'] = job['result']
|
||||
|
||||
202
routes/regenerate.py
Normal file
202
routes/regenerate.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint
|
||||
from services.llm import load_prompt, call_llm
|
||||
from services.sync import _sync_nsfw_from_tags
|
||||
from services.job_queue import _enqueue_task
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
# Map category string to (model class, id_field, config_dir_key)
|
||||
_CATEGORY_MAP = {
|
||||
'characters': (Character, 'character_id', 'CHARACTERS_DIR'),
|
||||
'outfits': (Outfit, 'outfit_id', 'CLOTHING_DIR'),
|
||||
'actions': (Action, 'action_id', 'ACTIONS_DIR'),
|
||||
'styles': (Style, 'style_id', 'STYLES_DIR'),
|
||||
'scenes': (Scene, 'scene_id', 'SCENES_DIR'),
|
||||
'detailers': (Detailer, 'detailer_id', 'DETAILERS_DIR'),
|
||||
'looks': (Look, 'look_id', 'LOOKS_DIR'),
|
||||
}
|
||||
|
||||
# Fields to preserve from the original data (never overwritten by LLM output)
|
||||
_PRESERVE_KEYS = {
|
||||
'lora', 'participants', 'suppress_wardrobe',
|
||||
'character_id', 'character_name',
|
||||
'outfit_id', 'outfit_name',
|
||||
'action_id', 'action_name',
|
||||
'style_id', 'style_name',
|
||||
'scene_id', 'scene_name',
|
||||
'detailer_id', 'detailer_name',
|
||||
'look_id', 'look_name',
|
||||
}
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
@app.route('/api/<category>/<path:slug>/regenerate_tags', methods=['POST'])
|
||||
def regenerate_tags(category, slug):
|
||||
if category not in _CATEGORY_MAP:
|
||||
return {'error': f'Unknown category: {category}'}, 400
|
||||
|
||||
model_class, id_field, dir_key = _CATEGORY_MAP[category]
|
||||
entity = model_class.query.filter_by(slug=slug).first()
|
||||
if not entity:
|
||||
return {'error': 'Not found'}, 404
|
||||
|
||||
system_prompt = load_prompt('regenerate_tags_system.txt')
|
||||
if not system_prompt:
|
||||
return {'error': 'Regenerate tags system prompt not found'}, 500
|
||||
|
||||
original_data = entity.data.copy()
|
||||
|
||||
try:
|
||||
prompt = (
|
||||
f"Regenerate the prompt tags for this {category.rstrip('s')} resource.\n"
|
||||
f"Current JSON:\n{json.dumps(original_data, indent=2)}"
|
||||
)
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
new_data = json.loads(clean_json)
|
||||
except Exception as e:
|
||||
logger.exception(f"Regenerate tags LLM error for {category}/{slug}")
|
||||
return {'error': f'LLM error: {str(e)}'}, 500
|
||||
|
||||
# Preserve protected fields from original
|
||||
for key in _PRESERVE_KEYS:
|
||||
if key in original_data:
|
||||
new_data[key] = original_data[key]
|
||||
|
||||
# Update DB
|
||||
entity.data = new_data
|
||||
flag_modified(entity, 'data')
|
||||
_sync_nsfw_from_tags(entity, new_data)
|
||||
db.session.commit()
|
||||
|
||||
# Write back to JSON file
|
||||
if entity.filename:
|
||||
file_path = os.path.join(current_app.config[dir_key], entity.filename)
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
|
||||
return {'success': True, 'data': new_data}
|
||||
|
||||
@app.route('/admin/migrate_tags', methods=['POST'])
|
||||
def migrate_tags():
|
||||
"""One-time migration: convert old list-format tags to new dict format."""
|
||||
migrated = 0
|
||||
for category, (model_class, id_field, dir_key) in _CATEGORY_MAP.items():
|
||||
entities = model_class.query.all()
|
||||
for entity in entities:
|
||||
tags = entity.data.get('tags')
|
||||
if isinstance(tags, list) or tags is None:
|
||||
new_data = entity.data.copy()
|
||||
new_data['tags'] = {'nsfw': False}
|
||||
entity.data = new_data
|
||||
flag_modified(entity, 'data')
|
||||
|
||||
# Write back to JSON file
|
||||
if entity.filename:
|
||||
file_path = os.path.join(current_app.config[dir_key], entity.filename)
|
||||
try:
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not write {file_path}: {e}")
|
||||
|
||||
migrated += 1
|
||||
|
||||
# Also handle checkpoints
|
||||
for ckpt in Checkpoint.query.all():
|
||||
data = ckpt.data or {}
|
||||
tags = data.get('tags')
|
||||
if isinstance(tags, list) or tags is None:
|
||||
new_data = data.copy()
|
||||
new_data['tags'] = {'nsfw': False}
|
||||
ckpt.data = new_data
|
||||
flag_modified(ckpt, 'data')
|
||||
migrated += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info(f"Migrated {migrated} resources from list tags to dict tags")
|
||||
return {'success': True, 'migrated': migrated}
|
||||
|
||||
def _make_regen_task(category, slug, name, system_prompt):
|
||||
"""Factory: create a tag regeneration task function for one entity."""
|
||||
def task_fn(job):
|
||||
model_class, id_field, dir_key = _CATEGORY_MAP[category]
|
||||
entity = model_class.query.filter_by(slug=slug).first()
|
||||
if not entity:
|
||||
raise Exception(f'{category}/{slug} not found')
|
||||
|
||||
original_data = entity.data.copy()
|
||||
prompt = (
|
||||
f"Regenerate the prompt tags for this {category.rstrip('s')} resource.\n"
|
||||
f"Current JSON:\n{json.dumps(original_data, indent=2)}"
|
||||
)
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
new_data = json.loads(clean_json)
|
||||
|
||||
for key in _PRESERVE_KEYS:
|
||||
if key in original_data:
|
||||
new_data[key] = original_data[key]
|
||||
|
||||
entity.data = new_data
|
||||
flag_modified(entity, 'data')
|
||||
_sync_nsfw_from_tags(entity, new_data)
|
||||
db.session.commit()
|
||||
|
||||
if entity.filename:
|
||||
file_path = os.path.join(current_app.config[dir_key], entity.filename)
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
|
||||
job['result'] = {'entity': name, 'status': 'updated'}
|
||||
return task_fn
|
||||
|
||||
@app.route('/admin/bulk_regenerate_tags/<category>', methods=['POST'])
|
||||
def bulk_regenerate_tags_category(category):
|
||||
"""Queue LLM tag regeneration for all resources in a single category."""
|
||||
if category not in _CATEGORY_MAP:
|
||||
return {'error': f'Unknown category: {category}'}, 400
|
||||
|
||||
system_prompt = load_prompt('regenerate_tags_system.txt')
|
||||
if not system_prompt:
|
||||
return {'error': 'Regenerate tags system prompt not found'}, 500
|
||||
|
||||
model_class, id_field, dir_key = _CATEGORY_MAP[category]
|
||||
entities = model_class.query.all()
|
||||
job_ids = []
|
||||
|
||||
for entity in entities:
|
||||
job = _enqueue_task(
|
||||
f"Regen tags: {entity.name} ({category})",
|
||||
_make_regen_task(category, entity.slug, entity.name, system_prompt)
|
||||
)
|
||||
job_ids.append(job['id'])
|
||||
|
||||
return {'success': True, 'queued': len(job_ids), 'job_ids': job_ids}
|
||||
|
||||
@app.route('/admin/bulk_regenerate_tags', methods=['POST'])
|
||||
def bulk_regenerate_tags():
|
||||
"""Queue LLM tag regeneration for all resources across all categories."""
|
||||
system_prompt = load_prompt('regenerate_tags_system.txt')
|
||||
if not system_prompt:
|
||||
return {'error': 'Regenerate tags system prompt not found'}, 500
|
||||
|
||||
job_ids = []
|
||||
for category, (model_class, id_field, dir_key) in _CATEGORY_MAP.items():
|
||||
entities = model_class.query.all()
|
||||
for entity in entities:
|
||||
job = _enqueue_task(
|
||||
f"Regen tags: {entity.name} ({category})",
|
||||
_make_regen_task(category, entity.slug, entity.name, system_prompt)
|
||||
)
|
||||
job_ids.append(job['id'])
|
||||
|
||||
return {'success': True, 'queued': len(job_ids), 'job_ids': job_ids}
|
||||
132
routes/scenes.py
132
routes/scenes.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, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look
|
||||
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
|
||||
from services.sync import sync_scenes
|
||||
from services.file_io import get_available_loras
|
||||
@@ -37,8 +36,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/scenes')
|
||||
def scenes_index():
|
||||
scenes = Scene.query.order_by(Scene.name).all()
|
||||
return render_template('scenes/index.html', scenes=scenes)
|
||||
query = Scene.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)
|
||||
scenes = query.order_by(Scene.is_favourite.desc(), Scene.name).all()
|
||||
return render_template('scenes/index.html', scenes=scenes, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/scenes/rescan', methods=['POST'])
|
||||
def rescan_scenes():
|
||||
@@ -117,9 +125,12 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
||||
# Update Tags (structured dict)
|
||||
new_data['tags'] = {
|
||||
'scene_type': request.form.get('tag_scene_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
scene.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
scene.data = new_data
|
||||
flag_modified(scene, "data")
|
||||
@@ -332,15 +343,16 @@ def register_routes(app):
|
||||
return redirect(url_for('scenes_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
skipped = 0
|
||||
job_ids = []
|
||||
|
||||
system_prompt = load_prompt('scene_system.txt')
|
||||
if not system_prompt:
|
||||
flash('Scene system prompt file not found.', 'error')
|
||||
return redirect(url_for('scenes_index'))
|
||||
|
||||
scenes_dir = app.config['SCENES_DIR']
|
||||
|
||||
for filename in os.listdir(backgrounds_lora_dir):
|
||||
if filename.endswith('.safetensors'):
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
@@ -348,11 +360,11 @@ def register_routes(app):
|
||||
scene_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
|
||||
json_filename = f"{scene_id}.json"
|
||||
json_path = os.path.join(app.config['SCENES_DIR'], json_filename)
|
||||
json_path = os.path.join(scenes_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"
|
||||
@@ -362,74 +374,69 @@ def register_routes(app):
|
||||
try:
|
||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||
html_raw = hf.read()
|
||||
# Strip HTML tags but keep text content for LLM context
|
||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
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 scene: {scene_name}")
|
||||
prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
def make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||
def task_fn(job):
|
||||
prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
scene_data = json.loads(clean_json)
|
||||
|
||||
# Clean response
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
scene_data = json.loads(clean_json)
|
||||
scene_data['scene_id'] = scene_id
|
||||
scene_data['scene_name'] = scene_name
|
||||
|
||||
# Enforce system values while preserving LLM-extracted metadata
|
||||
scene_data['scene_id'] = scene_id
|
||||
scene_data['scene_name'] = scene_name
|
||||
if 'lora' not in scene_data: scene_data['lora'] = {}
|
||||
scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
|
||||
if 'lora' not in scene_data: scene_data['lora'] = {}
|
||||
scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if not scene_data['lora'].get('lora_triggers'):
|
||||
scene_data['lora']['lora_triggers'] = name_base
|
||||
if scene_data['lora'].get('lora_weight') is None:
|
||||
scene_data['lora']['lora_weight'] = 1.0
|
||||
if scene_data['lora'].get('lora_weight_min') is None:
|
||||
scene_data['lora']['lora_weight_min'] = 0.7
|
||||
if scene_data['lora'].get('lora_weight_max') is None:
|
||||
scene_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
if not scene_data['lora'].get('lora_triggers'):
|
||||
scene_data['lora']['lora_triggers'] = name_base
|
||||
if scene_data['lora'].get('lora_weight') is None:
|
||||
scene_data['lora']['lora_weight'] = 1.0
|
||||
if scene_data['lora'].get('lora_weight_min') is None:
|
||||
scene_data['lora']['lora_weight_min'] = 0.7
|
||||
if scene_data['lora'].get('lora_weight_max') is None:
|
||||
scene_data['lora']['lora_weight_max'] = 1.0
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(scene_data, f, indent=2)
|
||||
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(scene_data, f, indent=2)
|
||||
job['result'] = {'name': scene_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||
return task_fn
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job = _enqueue_task(f"Create scene: {scene_name}", make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||
job_ids.append(job['id'])
|
||||
|
||||
# Small delay to avoid API rate limits if many files
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating scene for {filename}: {e}")
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
sync_scenes()
|
||||
msg = f'Successfully processed scenes: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No scenes created or overwritten. {skipped_count} existing scenes found.')
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_scenes()
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync scenes DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
flash(f'Queued {len(job_ids)} scene tasks ({skipped} skipped).')
|
||||
return redirect(url_for('scenes_index'))
|
||||
|
||||
@app.route('/scene/create', methods=['GET', 'POST'])
|
||||
def create_scene():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
|
||||
form_data = {'name': name, 'filename': slug}
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
@@ -478,9 +485,9 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create scene: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('scenes/create.html', form_data=form_data)
|
||||
|
||||
return render_template('scenes/create.html')
|
||||
return render_template('scenes/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/scene/<path:slug>/clone', methods=['POST'])
|
||||
def clone_scene(slug):
|
||||
@@ -538,3 +545,12 @@ def register_routes(app):
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/scene/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_scene_favourite(slug):
|
||||
scene = Scene.query.filter_by(slug=slug).first_or_404()
|
||||
scene.is_favourite = not scene.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': scene.is_favourite}
|
||||
return redirect(url_for('scene_detail', slug=slug))
|
||||
|
||||
209
routes/search.py
Normal file
209
routes/search.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, current_app
|
||||
from models import (
|
||||
db, Character, Look, Outfit, Action, Style, Scene, Detailer, Checkpoint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
# Category config: (model_class, id_field, name_field, url_prefix, detail_route)
|
||||
_SEARCH_CATEGORIES = {
|
||||
'characters': (Character, 'character_id', 'character_name', '/character', 'detail'),
|
||||
'looks': (Look, 'look_id', 'look_name', '/look', 'look_detail'),
|
||||
'outfits': (Outfit, 'outfit_id', 'outfit_name', '/outfit', 'outfit_detail'),
|
||||
'actions': (Action, 'action_id', 'action_name', '/action', 'action_detail'),
|
||||
'styles': (Style, 'style_id', 'style_name', '/style', 'style_detail'),
|
||||
'scenes': (Scene, 'scene_id', 'scene_name', '/scene', 'scene_detail'),
|
||||
'detailers': (Detailer, 'detailer_id', 'detailer_name', '/detailer', 'detailer_detail'),
|
||||
'checkpoints': (Checkpoint, 'checkpoint_id', 'checkpoint_name', '/checkpoint', 'checkpoint_detail'),
|
||||
}
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
def _search_resources(query_str, category_filter='all', nsfw_filter='all'):
|
||||
"""Search resources by name, tag values, and prompt field content."""
|
||||
results = []
|
||||
q = query_str.lower()
|
||||
|
||||
categories = _SEARCH_CATEGORIES if category_filter == 'all' else {category_filter: _SEARCH_CATEGORIES.get(category_filter)}
|
||||
categories = {k: v for k, v in categories.items() if v}
|
||||
|
||||
for cat_name, (model_class, id_field, name_field, url_prefix, detail_route) in categories.items():
|
||||
entities = model_class.query.all()
|
||||
for entity in entities:
|
||||
# Apply NSFW filter
|
||||
if nsfw_filter == 'sfw' and getattr(entity, 'is_nsfw', False):
|
||||
continue
|
||||
if nsfw_filter == 'nsfw' and not getattr(entity, 'is_nsfw', False):
|
||||
continue
|
||||
|
||||
match_context = None
|
||||
score = 0
|
||||
|
||||
# 1. Name match (highest priority)
|
||||
if q in entity.name.lower():
|
||||
score = 100 if entity.name.lower() == q else 90
|
||||
match_context = f"Name: {entity.name}"
|
||||
|
||||
# 2. Tag match
|
||||
if not match_context:
|
||||
data = entity.data or {}
|
||||
tags = data.get('tags', {})
|
||||
if isinstance(tags, dict):
|
||||
for key, val in tags.items():
|
||||
if key == 'nsfw':
|
||||
continue
|
||||
if isinstance(val, str) and q in val.lower():
|
||||
score = 70
|
||||
match_context = f"Tag {key}: {val}"
|
||||
break
|
||||
elif isinstance(val, list):
|
||||
for item in val:
|
||||
if isinstance(item, str) and q in item.lower():
|
||||
score = 70
|
||||
match_context = f"Tag {key}: {item}"
|
||||
break
|
||||
|
||||
# 3. Prompt field match (search JSON data values)
|
||||
if not match_context:
|
||||
data_str = json.dumps(entity.data or {}).lower()
|
||||
if q in data_str:
|
||||
score = 30
|
||||
# Find which field matched for context
|
||||
data = entity.data or {}
|
||||
for section_key, section_val in data.items():
|
||||
if section_key in ('lora', 'tags'):
|
||||
continue
|
||||
if isinstance(section_val, dict):
|
||||
for field_key, field_val in section_val.items():
|
||||
if isinstance(field_val, str) and q in field_val.lower():
|
||||
match_context = f"{section_key}.{field_key}: ...{field_val[:80]}..."
|
||||
break
|
||||
elif isinstance(section_val, str) and q in section_val.lower():
|
||||
match_context = f"{section_key}: ...{section_val[:80]}..."
|
||||
if match_context:
|
||||
break
|
||||
if not match_context:
|
||||
match_context = "Matched in data"
|
||||
|
||||
if match_context:
|
||||
results.append({
|
||||
'type': cat_name,
|
||||
'slug': entity.slug,
|
||||
'name': entity.name,
|
||||
'match_context': match_context,
|
||||
'score': score,
|
||||
'is_favourite': getattr(entity, 'is_favourite', False),
|
||||
'is_nsfw': getattr(entity, 'is_nsfw', False),
|
||||
'image_path': entity.image_path,
|
||||
'detail_route': detail_route,
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: (-x['score'], x['name'].lower()))
|
||||
return results
|
||||
|
||||
def _search_images(query_str, nsfw_filter='all'):
|
||||
"""Search gallery images by prompt metadata in sidecar JSON files."""
|
||||
results = []
|
||||
q = query_str.lower()
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
|
||||
gallery_cats = ['characters', 'actions', 'outfits', 'scenes', 'styles',
|
||||
'detailers', 'checkpoints', 'looks', 'presets', 'generator']
|
||||
|
||||
for cat in gallery_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:
|
||||
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('.json'):
|
||||
continue
|
||||
# This is a sidecar JSON
|
||||
sidecar_path = os.path.join(item_folder, filename)
|
||||
try:
|
||||
with open(sidecar_path) as f:
|
||||
sidecar = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
# NSFW filter
|
||||
if nsfw_filter == 'sfw' and sidecar.get('is_nsfw'):
|
||||
continue
|
||||
if nsfw_filter == 'nsfw' and not sidecar.get('is_nsfw'):
|
||||
continue
|
||||
|
||||
# Search positive/negative prompts stored in sidecar
|
||||
sidecar_str = json.dumps(sidecar).lower()
|
||||
if q in sidecar_str:
|
||||
img_filename = filename.rsplit('.', 1)[0]
|
||||
# Find the actual image file
|
||||
img_path = None
|
||||
for ext in ('.png', '.jpg', '.jpeg', '.webp'):
|
||||
candidate = f"{cat}/{item_slug}/{img_filename}{ext}"
|
||||
if os.path.isfile(os.path.join(upload_folder, candidate)):
|
||||
img_path = candidate
|
||||
break
|
||||
|
||||
if img_path:
|
||||
results.append({
|
||||
'path': img_path,
|
||||
'category': cat,
|
||||
'slug': item_slug,
|
||||
'is_favourite': sidecar.get('is_favourite', False),
|
||||
'is_nsfw': sidecar.get('is_nsfw', False),
|
||||
'match_context': f"Found in sidecar metadata",
|
||||
})
|
||||
|
||||
if len(results) >= 50:
|
||||
break
|
||||
|
||||
return results[:50]
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
q = request.args.get('q', '').strip()
|
||||
category = request.args.get('category', 'all')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
search_type = request.args.get('type', 'all')
|
||||
|
||||
resources = []
|
||||
images = []
|
||||
|
||||
if q:
|
||||
if search_type in ('all', 'resources'):
|
||||
resources = _search_resources(q, category, nsfw)
|
||||
if search_type in ('all', 'images'):
|
||||
images = _search_images(q, nsfw)
|
||||
|
||||
# Group resources by type
|
||||
grouped = {}
|
||||
for r in resources:
|
||||
grouped.setdefault(r['type'], []).append(r)
|
||||
|
||||
return render_template(
|
||||
'search.html',
|
||||
query=q,
|
||||
category=category,
|
||||
nsfw_filter=nsfw,
|
||||
search_type=search_type,
|
||||
grouped_resources=grouped,
|
||||
images=images,
|
||||
total_resources=len(resources),
|
||||
total_images=len(images),
|
||||
)
|
||||
@@ -70,7 +70,6 @@ def register_routes(app):
|
||||
if category == 'outfits':
|
||||
wardrobe = entity.data.get('wardrobe', {})
|
||||
outfit_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||
tags = entity.data.get('tags', [])
|
||||
wardrobe_parts = [v for v in wardrobe.values() if isinstance(v, str) and v]
|
||||
char_parts = []
|
||||
face_parts = []
|
||||
@@ -83,7 +82,7 @@ def register_routes(app):
|
||||
face_parts = [v for v in [identity.get('head'),
|
||||
defaults.get('expression')] if v]
|
||||
hand_parts = [v for v in [wardrobe.get('hands')] if v]
|
||||
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags
|
||||
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts
|
||||
return {
|
||||
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
||||
'face': _dedup_tags(', '.join(face_parts)),
|
||||
@@ -93,7 +92,6 @@ def register_routes(app):
|
||||
if category == 'actions':
|
||||
action_data = entity.data.get('action', {})
|
||||
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||
tags = entity.data.get('tags', [])
|
||||
from utils import _BODY_GROUP_KEYS
|
||||
pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
|
||||
expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
|
||||
@@ -104,7 +102,7 @@ def register_routes(app):
|
||||
identity = character.data.get('identity', {})
|
||||
char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
|
||||
face_parts = [v for v in [identity.get('head')] + expr_parts if v]
|
||||
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
|
||||
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts
|
||||
return {
|
||||
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
||||
'face': _dedup_tags(', '.join(face_parts)),
|
||||
@@ -113,20 +111,19 @@ def register_routes(app):
|
||||
|
||||
# styles / scenes / detailers
|
||||
entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||
tags = entity.data.get('tags', [])
|
||||
|
||||
if category == 'styles':
|
||||
sdata = entity.data.get('style', {})
|
||||
artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else ''
|
||||
style_tags = sdata.get('artistic_style', '')
|
||||
entity_parts = [p for p in [entity_triggers, artist, style_tags] + tags if p]
|
||||
entity_parts = [p for p in [entity_triggers, artist, style_tags] if p]
|
||||
elif category == 'scenes':
|
||||
sdata = entity.data.get('scene', {})
|
||||
scene_parts = [v for v in sdata.values() if isinstance(v, str) and v]
|
||||
entity_parts = [p for p in [entity_triggers] + scene_parts + tags if p]
|
||||
entity_parts = [p for p in [entity_triggers] + scene_parts if p]
|
||||
else: # detailers
|
||||
det_prompt = entity.data.get('prompt', '')
|
||||
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p]
|
||||
entity_parts = [p for p in [entity_triggers, det_prompt] if p]
|
||||
|
||||
char_data_no_lora = _get_character_data_without_lora(character)
|
||||
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''}
|
||||
|
||||
159
routes/styles.py
159
routes/styles.py
@@ -2,7 +2,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import random
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Style, Detailer, Settings
|
||||
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
|
||||
from services.sync import sync_styles
|
||||
from services.file_io import get_available_loras
|
||||
@@ -82,8 +81,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/styles')
|
||||
def styles_index():
|
||||
styles = Style.query.order_by(Style.name).all()
|
||||
return render_template('styles/index.html', styles=styles)
|
||||
query = Style.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)
|
||||
styles = query.order_by(Style.is_favourite.desc(), Style.name).all()
|
||||
return render_template('styles/index.html', styles=styles, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/styles/rescan', methods=['POST'])
|
||||
def rescan_styles():
|
||||
@@ -158,6 +166,13 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (structured dict)
|
||||
new_data['tags'] = {
|
||||
'style_type': request.form.get('tag_style_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
style.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
style.data = new_data
|
||||
flag_modified(style, "data")
|
||||
|
||||
@@ -343,66 +358,73 @@ def register_routes(app):
|
||||
styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(styles_lora_dir)
|
||||
if not os.path.exists(styles_lora_dir):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Styles LoRA directory not found.'}, 400
|
||||
flash('Styles LoRA directory not found.', 'error')
|
||||
return redirect(url_for('styles_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
|
||||
system_prompt = load_prompt('style_system.txt')
|
||||
if not system_prompt:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Style system prompt file not found.'}, 500
|
||||
flash('Style system prompt file not found.', 'error')
|
||||
return redirect(url_for('styles_index'))
|
||||
|
||||
for filename in os.listdir(styles_lora_dir):
|
||||
if filename.endswith('.safetensors'):
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
||||
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
job_ids = []
|
||||
skipped = 0
|
||||
|
||||
json_filename = f"{style_id}.json"
|
||||
json_path = os.path.join(app.config['STYLES_DIR'], json_filename)
|
||||
for filename in sorted(os.listdir(styles_lora_dir)):
|
||||
if not filename.endswith('.safetensors'):
|
||||
continue
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
continue
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
||||
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
html_path = os.path.join(styles_lora_dir, html_filename)
|
||||
html_content = ""
|
||||
if os.path.exists(html_path):
|
||||
try:
|
||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||
html_raw = hf.read()
|
||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
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}")
|
||||
json_filename = f"{style_id}.json"
|
||||
json_path = os.path.join(app.config['STYLES_DIR'], json_filename)
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Read HTML companion file if it exists
|
||||
html_path = os.path.join(styles_lora_dir, f"{name_base}.html")
|
||||
html_content = ""
|
||||
if os.path.exists(html_path):
|
||||
try:
|
||||
print(f"Asking LLM to describe style: {style_name}")
|
||||
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||
html_raw = hf.read()
|
||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
def make_task(fn, sid, sname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||
def task_fn(job):
|
||||
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{fn}'"
|
||||
if html_ctx:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, sys_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
style_data = json.loads(clean_json)
|
||||
|
||||
style_data['style_id'] = style_id
|
||||
style_data['style_name'] = style_name
|
||||
style_data['style_id'] = sid
|
||||
style_data['style_name'] = sname
|
||||
|
||||
if 'lora' not in style_data: style_data['lora'] = {}
|
||||
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if 'lora' not in style_data:
|
||||
style_data['lora'] = {}
|
||||
style_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||
|
||||
if not style_data['lora'].get('lora_triggers'):
|
||||
style_data['lora']['lora_triggers'] = name_base
|
||||
style_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||
if style_data['lora'].get('lora_weight') is None:
|
||||
style_data['lora']['lora_weight'] = 1.0
|
||||
if style_data['lora'].get('lora_weight_min') is None:
|
||||
@@ -410,35 +432,43 @@ def register_routes(app):
|
||||
if style_data['lora'].get('lora_weight_max') is None:
|
||||
style_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
with open(json_path, 'w') as f:
|
||||
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||
with open(jp, 'w') as f:
|
||||
json.dump(style_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': sname, 'action': 'overwritten' if is_exist else 'created'}
|
||||
return task_fn
|
||||
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"Error creating style for {filename}: {e}")
|
||||
job = _enqueue_task(
|
||||
f"Create style: {style_name}",
|
||||
make_task(filename, style_id, style_name, json_path,
|
||||
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||
)
|
||||
job_ids.append(job['id'])
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
sync_styles()
|
||||
msg = f'Successfully processed styles: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No styles created or overwritten. {skipped_count} existing styles found.')
|
||||
# Enqueue a sync task to run after all creates
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_styles()
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync styles DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
|
||||
flash(f'Queued {len(job_ids)} style creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||
return redirect(url_for('styles_index'))
|
||||
|
||||
@app.route('/style/create', methods=['GET', 'POST'])
|
||||
def create_style():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
|
||||
form_data = {'name': name, 'filename': slug}
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
@@ -483,9 +513,9 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create style: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('styles/create.html', form_data=form_data)
|
||||
|
||||
return render_template('styles/create.html')
|
||||
return render_template('styles/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/style/<path:slug>/clone', methods=['POST'])
|
||||
def clone_style(slug):
|
||||
@@ -542,3 +572,12 @@ def register_routes(app):
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/style/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_style_favourite(slug):
|
||||
style_obj = Style.query.filter_by(slug=slug).first_or_404()
|
||||
style_obj.is_favourite = not style_obj.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': style_obj.is_favourite}
|
||||
return redirect(url_for('style_detail', slug=slug))
|
||||
|
||||
Reference in New Issue
Block a user