3 Commits

Author SHA1 Message Date
Aodhan Collins
e226548471 Merge branch 'logging'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:02:41 +00:00
Aodhan Collins
b9196ef5f5 Add structured logging for job queue and workflow prompts
Replace bare print() calls with Python logging module. Job lifecycle
(enqueue, start, ComfyUI acceptance, completion, failure) now emits
timestamped INFO logs to stdout, captured by Docker. Failures use
logger.exception() for full tracebacks. Workflow prompt block logs as
a single INFO entry; LoRA chain details moved to DEBUG level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:55:53 +00:00
Aodhan Collins
a38915b354 Refactor UI, settings, and code quality across all categories
- Fix Replace Cover: routes now read preview_path from form POST instead of session (session writes from background threads were lost)
- Fix batch generation: submit all jobs immediately, poll all in parallel via Promise.all
- Fix label NameError in character generate route
- Fix style detail missing characters context
- Selected Preview pane: click any image to select it; data-preview-path on all images across all 8 detail templates
- Gallery → Library rename across all index page headings and navbar
- Settings: add configurable LoRA/checkpoint directories; default checkpoint selector moved from navbar to settings page
- Consolidate 6 get_available_*_loras() into single get_available_loras(category) reading from Settings
- ComfyUI tooltip shows currently loaded checkpoint name
- Remove navbar checkpoint bar
- Phase 4 cleanup: remove dead _queue_generation(), add session.modified, standardize log prefixes, rename action_type → action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:48:28 +00:00
21 changed files with 793 additions and 785 deletions

374
app.py
View File

@@ -1,5 +1,6 @@
import os
import json
import logging
import time
import re
import requests
@@ -42,6 +43,16 @@ app.config['SESSION_PERMANENT'] = False
db.init_app(app)
Session(app)
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
logger = logging.getLogger('gaze')
# ---------------------------------------------------------------------------
# Generation Job Queue
# ---------------------------------------------------------------------------
@@ -78,6 +89,7 @@ def _enqueue_job(label, workflow, finalize_fn):
with _job_queue_lock:
_job_queue.append(job)
_job_history[job['id']] = job
logger.info("Job queued: [%s] %s", job['id'][:8], label)
_queue_worker_event.set()
return job
@@ -104,6 +116,8 @@ def _queue_worker():
with _job_queue_lock:
job['status'] = 'processing'
logger.info("Job started: [%s] %s", job['id'][:8], job['label'])
try:
with app.app_context():
# Send workflow to ComfyUI
@@ -114,6 +128,7 @@ def _queue_worker():
comfy_id = prompt_response['prompt_id']
with _job_queue_lock:
job['comfy_prompt_id'] = comfy_id
logger.info("Job [%s] queued in ComfyUI as %s", job['id'][:8], comfy_id)
# Poll until done (max ~10 minutes)
max_retries = 300
@@ -129,15 +144,17 @@ def _queue_worker():
if not finished:
raise Exception("ComfyUI generation timed out")
logger.info("Job [%s] generation complete, finalizing...", job['id'][:8])
# Run the finalize callback (saves image to disk / DB)
# finalize_fn(comfy_prompt_id, job) — job is passed so callback can store result
job['finalize_fn'](comfy_id, job)
with _job_queue_lock:
job['status'] = 'done'
logger.info("Job done: [%s] %s", job['id'][:8], job['label'])
except Exception as e:
print(f"[Queue] Job {job['id']} failed: {e}")
logger.exception("Job failed: [%s] %s%s", job['id'][:8], job['label'], e)
with _job_queue_lock:
job['status'] = 'failed'
job['error'] = str(e)
@@ -385,6 +402,7 @@ def inject_default_checkpoint():
@app.route('/set_default_checkpoint', methods=['POST'])
def set_default_checkpoint():
session['default_checkpoint'] = request.form.get('checkpoint_path', '')
session.modified = True
return {'status': 'ok'}
@@ -401,6 +419,27 @@ def api_status_comfyui():
return {'status': 'error'}
@app.route('/api/comfyui/loaded_checkpoint')
def api_comfyui_loaded_checkpoint():
"""Return the checkpoint name from the most recently completed ComfyUI job."""
url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188')
try:
resp = requests.get(f'{url}/history', timeout=3)
if not resp.ok:
return {'checkpoint': None}
history = resp.json()
if not history:
return {'checkpoint': None}
# Sort by timestamp descending, take the most recent job
latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', ''))
# Node "4" is the checkpoint loader in the workflow
nodes = latest.get('prompt', [None, None, {}])[2]
ckpt_name = nodes.get('4', {}).get('inputs', {}).get('ckpt_name')
return {'checkpoint': ckpt_name}
except Exception:
return {'checkpoint': None}
@app.route('/api/status/mcp')
def api_status_mcp():
"""Return whether the danbooru-mcp Docker container is running."""
@@ -417,80 +456,39 @@ def api_status_mcp():
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
def get_available_loras():
loras = []
if os.path.exists(app.config['LORA_DIR']):
for f in os.listdir(app.config['LORA_DIR']):
if f.endswith('.safetensors'):
# Using the format seen in character JSONs
loras.append(f"Illustrious/Looks/{f}")
return sorted(loras)
_LORA_DEFAULTS = {
'characters': '/ImageModels/lora/Illustrious/Looks',
'outfits': '/ImageModels/lora/Illustrious/Clothing',
'actions': '/ImageModels/lora/Illustrious/Poses',
'styles': '/ImageModels/lora/Illustrious/Styles',
'scenes': '/ImageModels/lora/Illustrious/Backgrounds',
'detailers': '/ImageModels/lora/Illustrious/Detailers',
}
def get_available_clothing_loras():
"""Get LoRAs from the Clothing directory for outfit LoRAs."""
clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/'
loras = []
if os.path.exists(clothing_lora_dir):
for f in os.listdir(clothing_lora_dir):
if f.endswith('.safetensors'):
loras.append(f"Illustrious/Clothing/{f}")
return sorted(loras)
def get_available_action_loras():
"""Get LoRAs from the Poses directory for action LoRAs."""
poses_lora_dir = '/ImageModels/lora/Illustrious/Poses/'
loras = []
if os.path.exists(poses_lora_dir):
for f in os.listdir(poses_lora_dir):
if f.endswith('.safetensors'):
loras.append(f"Illustrious/Poses/{f}")
return sorted(loras)
def get_available_style_loras():
"""Get LoRAs from the Styles directory for style LoRAs."""
styles_lora_dir = '/ImageModels/lora/Illustrious/Styles/'
loras = []
if os.path.exists(styles_lora_dir):
for f in os.listdir(styles_lora_dir):
if f.endswith('.safetensors'):
loras.append(f"Illustrious/Styles/{f}")
return sorted(loras)
def get_available_detailer_loras():
"""Get LoRAs from the Detailers directory for detailer LoRAs."""
detailers_lora_dir = '/ImageModels/lora/Illustrious/Detailers/'
loras = []
if os.path.exists(detailers_lora_dir):
for f in os.listdir(detailers_lora_dir):
if f.endswith('.safetensors'):
loras.append(f"Illustrious/Detailers/{f}")
return sorted(loras)
def get_available_scene_loras():
"""Get LoRAs from the Backgrounds directory for scene LoRAs."""
backgrounds_lora_dir = '/ImageModels/lora/Illustrious/Backgrounds/'
loras = []
if os.path.exists(backgrounds_lora_dir):
for f in os.listdir(backgrounds_lora_dir):
if f.endswith('.safetensors'):
loras.append(f"Illustrious/Backgrounds/{f}")
return sorted(loras)
def get_available_loras(category):
"""Return sorted list of LoRA paths for the given category.
category: one of 'characters','outfits','actions','styles','scenes','detailers'
"""
settings = Settings.query.first()
lora_dir = (getattr(settings, f'lora_dir_{category}', None) if settings else None) or _LORA_DEFAULTS.get(category, '')
if not lora_dir or not os.path.isdir(lora_dir):
return []
subfolder = os.path.basename(lora_dir.rstrip('/'))
return sorted(f"Illustrious/{subfolder}/{f}" for f in os.listdir(lora_dir) if f.endswith('.safetensors'))
def get_available_checkpoints():
settings = Settings.query.first()
checkpoint_dirs_str = (settings.checkpoint_dirs if settings else None) or \
'/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'
checkpoints = []
# Scan Illustrious
if os.path.exists(app.config['ILLUSTRIOUS_MODELS_DIR']):
for f in os.listdir(app.config['ILLUSTRIOUS_MODELS_DIR']):
for ckpt_dir in checkpoint_dirs_str.split(','):
ckpt_dir = ckpt_dir.strip()
if not ckpt_dir or not os.path.isdir(ckpt_dir):
continue
prefix = os.path.basename(ckpt_dir.rstrip('/'))
for f in os.listdir(ckpt_dir):
if f.endswith('.safetensors') or f.endswith('.ckpt'):
checkpoints.append(f"Illustrious/{f}")
# Scan Noob
if os.path.exists(app.config['NOOB_MODELS_DIR']):
for f in os.listdir(app.config['NOOB_MODELS_DIR']):
if f.endswith('.safetensors') or f.endswith('.ckpt'):
checkpoints.append(f"Noob/{f}")
checkpoints.append(f"{prefix}/{f}")
return sorted(checkpoints)
def allowed_file(filename):
@@ -1484,10 +1482,17 @@ def settings():
settings.openrouter_model = request.form.get('model')
settings.local_base_url = request.form.get('local_base_url')
settings.local_model = request.form.get('local_model')
settings.lora_dir_characters = request.form.get('lora_dir_characters') or settings.lora_dir_characters
settings.lora_dir_outfits = request.form.get('lora_dir_outfits') or settings.lora_dir_outfits
settings.lora_dir_actions = request.form.get('lora_dir_actions') or settings.lora_dir_actions
settings.lora_dir_styles = request.form.get('lora_dir_styles') or settings.lora_dir_styles
settings.lora_dir_scenes = request.form.get('lora_dir_scenes') or settings.lora_dir_scenes
settings.lora_dir_detailers = request.form.get('lora_dir_detailers') or settings.lora_dir_detailers
settings.checkpoint_dirs = request.form.get('checkpoint_dirs') or settings.checkpoint_dirs
db.session.commit()
flash('Settings updated successfully!')
return redirect(url_for('settings'))
return render_template('settings.html', settings=settings)
@app.route('/')
@@ -1823,7 +1828,7 @@ def create_character():
@app.route('/character/<path:slug>/edit', methods=['GET', 'POST'])
def edit_character(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
loras = get_available_loras()
loras = get_available_loras('characters')
char_looks = Look.query.filter_by(character_id=character.character_id).order_by(Look.name).all()
if request.method == 'POST':
@@ -2080,28 +2085,18 @@ def upload_image(slug):
@app.route('/character/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_cover_from_preview(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_{slug}')
if preview_path:
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
character.image_path = preview_path
db.session.commit()
flash('Cover image updated from preview!')
flash('Cover image updated!')
else:
flash('No preview image available', 'error')
flash('No valid preview image selected.', 'error')
return redirect(url_for('detail', slug=slug))
def _log_workflow_prompts(label, workflow):
"""Print the final assembled ComfyUI prompts in a consistent, readable block."""
"""Log the final assembled ComfyUI prompts in a consistent, readable block."""
sep = "=" * 72
print(f"\n{sep}")
print(f" WORKFLOW PROMPTS [{label}]")
print(sep)
print(f" Checkpoint : {workflow['4']['inputs'].get('ckpt_name', '(not set)')}")
print(f" Seed : {workflow['3']['inputs'].get('seed', '(not set)')}")
print(f" Resolution : {workflow['5']['inputs'].get('width', '?')} x {workflow['5']['inputs'].get('height', '?')}")
print(f" Sampler : {workflow['3']['inputs'].get('sampler_name', '?')} / {workflow['3']['inputs'].get('scheduler', '?')} steps={workflow['3']['inputs'].get('steps', '?')} cfg={workflow['3']['inputs'].get('cfg', '?')}")
# LoRA chain summary
active_loras = []
for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]:
if node_id in workflow:
@@ -2109,16 +2104,26 @@ def _log_workflow_prompts(label, workflow):
if name:
w = workflow[node_id]["inputs"].get("strength_model", "?")
active_loras.append(f"{label_str}:{name.split('/')[-1]}@{w:.3f}" if isinstance(w, float) else f"{label_str}:{name.split('/')[-1]}@{w}")
print(f" LoRAs : {' | '.join(active_loras) if active_loras else '(none)'}")
print(f" [+] Positive : {workflow['6']['inputs'].get('text', '')}")
print(f" [-] Negative : {workflow['7']['inputs'].get('text', '')}")
face_text = workflow.get('14', {}).get('inputs', {}).get('text', '')
hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '')
lines = [
sep,
f" WORKFLOW PROMPTS [{label}]",
sep,
f" Checkpoint : {workflow['4']['inputs'].get('ckpt_name', '(not set)')}",
f" Seed : {workflow['3']['inputs'].get('seed', '(not set)')}",
f" Resolution : {workflow['5']['inputs'].get('width', '?')} x {workflow['5']['inputs'].get('height', '?')}",
f" Sampler : {workflow['3']['inputs'].get('sampler_name', '?')} / {workflow['3']['inputs'].get('scheduler', '?')} steps={workflow['3']['inputs'].get('steps', '?')} cfg={workflow['3']['inputs'].get('cfg', '?')}",
f" LoRAs : {' | '.join(active_loras) if active_loras else '(none)'}",
f" [+] Positive : {workflow['6']['inputs'].get('text', '')}",
f" [-] Negative : {workflow['7']['inputs'].get('text', '')}",
]
if face_text:
print(f" [F] Face : {face_text}")
lines.append(f" [F] Face : {face_text}")
if hand_text:
print(f" [H] Hand : {hand_text}")
print(f"{sep}\n")
lines.append(f" [H] Hand : {hand_text}")
lines.append(sep)
logger.info("\n%s", "\n".join(lines))
def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None, action=None, style=None, detailer=None, scene=None, width=None, height=None, checkpoint_data=None, look=None, fixed_seed=None):
@@ -2164,7 +2169,7 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
workflow["16"]["inputs"]["clip"] = ["4", 1] # From checkpoint
model_source = ["16", 0]
clip_source = ["16", 1]
print(f"Character LoRA: {char_lora_name} @ {_w16}")
logger.debug("Character LoRA: %s @ %s", char_lora_name, _w16)
# Outfit LoRA (Node 17) - chains from character LoRA or checkpoint
outfit_lora_data = outfit.data.get('lora', {}) if outfit else {}
@@ -2180,7 +2185,7 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
workflow["17"]["inputs"]["clip"] = clip_source
model_source = ["17", 0]
clip_source = ["17", 1]
print(f"Outfit LoRA: {outfit_lora_name} @ {_w17}")
logger.debug("Outfit LoRA: %s @ %s", outfit_lora_name, _w17)
# Action LoRA (Node 18) - chains from previous LoRA or checkpoint
action_lora_data = action.data.get('lora', {}) if action else {}
@@ -2196,7 +2201,7 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
workflow["18"]["inputs"]["clip"] = clip_source
model_source = ["18", 0]
clip_source = ["18", 1]
print(f"Action LoRA: {action_lora_name} @ {_w18}")
logger.debug("Action LoRA: %s @ %s", action_lora_name, _w18)
# Style/Detailer/Scene LoRA (Node 19) - chains from previous LoRA or checkpoint
# Priority: Style > Detailer > Scene (Scene LoRAs are rare but supported)
@@ -2214,7 +2219,7 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega
workflow["19"]["inputs"]["clip"] = clip_source
model_source = ["19", 0]
clip_source = ["19", 1]
print(f"Style/Detailer LoRA: {style_lora_name} @ {_w19}")
logger.debug("Style/Detailer LoRA: %s @ %s", style_lora_name, _w19)
# Apply connections to all model/clip consumers
workflow["3"]["inputs"]["model"] = model_source
@@ -2278,19 +2283,6 @@ def _get_default_checkpoint():
return None, None
return ckpt.checkpoint_path, ckpt.data or {}
def _queue_generation(character, action='preview', selected_fields=None, client_id=None):
# 1. Load workflow
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
# 2. Build prompts with active outfit
prompts = build_prompt(character.data, selected_fields, character.default_fields, character.active_outfit)
# 3. Prepare workflow
ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
return queue_prompt(workflow, client_id=client_id)
@app.route('/get_missing_characters')
def get_missing_characters():
missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).all()
@@ -2345,7 +2337,8 @@ def generate_image(slug):
# Save preferences
session[f'prefs_{slug}'] = selected_fields
session.modified = True
# Build workflow
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
@@ -2353,6 +2346,7 @@ def generate_image(slug):
ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
label = f"{character.name} {action}"
job = _enqueue_job(label, workflow, _make_finalize('characters', slug, Character, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
@@ -2428,7 +2422,9 @@ def rescan_outfits():
@app.route('/outfits/bulk_create', methods=['POST'])
def bulk_create_outfits_from_loras():
clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/'
_s = Settings.query.first()
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):
flash('Clothing LoRA directory not found.', 'error')
return redirect(url_for('outfits_index'))
@@ -2489,7 +2485,7 @@ def bulk_create_outfits_from_loras():
if 'lora' not in outfit_data:
outfit_data['lora'] = {}
outfit_data['lora']['lora_name'] = f"Illustrious/Clothing/{filename}"
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:
@@ -2548,7 +2544,7 @@ def outfit_detail(slug):
@app.route('/outfit/<path:slug>/edit', methods=['GET', 'POST'])
def edit_outfit(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
loras = get_available_clothing_loras() # Use clothing LoRAs for outfits
loras = get_available_loras('outfits') # Use clothing LoRAs for outfits
if request.method == 'POST':
try:
@@ -2667,7 +2663,8 @@ def generate_outfit_image(slug):
# Save preferences
session[f'prefs_outfit_{slug}'] = selected_fields
session[f'char_outfit_{slug}'] = character_slug
session.modified = True
# Build combined data for prompt building
if character:
# Combine character identity/defaults with outfit wardrobe
@@ -2743,15 +2740,13 @@ def generate_outfit_image(slug):
@app.route('/outfit/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_outfit_cover_from_preview(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_outfit_{slug}')
if preview_path:
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
outfit.image_path = preview_path
db.session.commit()
flash('Cover image updated from preview!')
flash('Cover image updated!')
else:
flash('No preview image available', 'error')
flash('No valid preview image selected.', 'error')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/create', methods=['GET', 'POST'])
@@ -2988,7 +2983,7 @@ def action_detail(slug):
@app.route('/action/<path:slug>/edit', methods=['GET', 'POST'])
def edit_action(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
loras = get_available_action_loras()
loras = get_available_loras('actions')
if request.method == 'POST':
try:
@@ -3091,7 +3086,7 @@ def generate_action_image(slug):
try:
# Get action type
action_type = request.form.get('action', 'preview')
action = request.form.get('action', 'preview')
# Get selected fields
selected_fields = request.form.getlist('include_field')
@@ -3107,7 +3102,8 @@ def generate_action_image(slug):
# Save preferences
session[f'char_action_{slug}'] = character_slug
session[f'prefs_action_{slug}'] = selected_fields
session.modified = True
# Build combined data for prompt building
if character:
# Combine character identity/wardrobe with action details
@@ -3245,8 +3241,8 @@ def generate_action_image(slug):
workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
char_label = character.name if character else 'no character'
label = f"Action: {action_obj.name} ({char_label}) {action_type}"
job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action_type))
label = f"Action: {action_obj.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
@@ -3263,15 +3259,13 @@ def generate_action_image(slug):
@app.route('/action/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_action_cover_from_preview(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_action_{slug}')
if preview_path:
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
action.image_path = preview_path
db.session.commit()
flash('Cover image updated from preview!')
flash('Cover image updated!')
else:
flash('No preview image available', 'error')
flash('No valid preview image selected.', 'error')
return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/save_defaults', methods=['POST'])
@@ -3285,7 +3279,9 @@ def save_action_defaults(slug):
@app.route('/actions/bulk_create', methods=['POST'])
def bulk_create_actions_from_loras():
actions_lora_dir = '/ImageModels/lora/Illustrious/Poses/'
_s = Settings.query.first()
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):
flash('Actions LoRA directory not found.', 'error')
return redirect(url_for('actions_index'))
@@ -3348,7 +3344,7 @@ def bulk_create_actions_from_loras():
# Update lora dict safely
if 'lora' not in action_data: action_data['lora'] = {}
action_data['lora']['lora_name'] = f"Illustrious/Poses/{filename}"
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
# Fallbacks if LLM failed to extract metadata
if not action_data['lora'].get('lora_triggers'):
@@ -3554,7 +3550,7 @@ def style_detail(slug):
@app.route('/style/<path:slug>/edit', methods=['GET', 'POST'])
def edit_style(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
loras = get_available_style_loras()
loras = get_available_loras('styles')
if request.method == 'POST':
try:
@@ -3718,7 +3714,8 @@ def generate_style_image(slug):
# Save preferences
session[f'char_style_{slug}'] = character_slug
session[f'prefs_style_{slug}'] = selected_fields
session.modified = True
# Build workflow using helper (returns workflow dict, not prompt_response)
workflow = _build_style_workflow(style_obj, character, selected_fields)
@@ -3750,15 +3747,13 @@ def save_style_defaults(slug):
@app.route('/style/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_style_cover_from_preview(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_style_{slug}')
if preview_path:
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
style.image_path = preview_path
db.session.commit()
flash('Cover image updated from preview!')
flash('Cover image updated!')
else:
flash('No preview image available', 'error')
flash('No valid preview image selected.', 'error')
return redirect(url_for('style_detail', slug=slug))
@app.route('/get_missing_styles')
@@ -3819,7 +3814,9 @@ def generate_missing_styles():
@app.route('/styles/bulk_create', methods=['POST'])
def bulk_create_styles_from_loras():
styles_lora_dir = '/ImageModels/lora/Illustrious/Styles/'
_s = Settings.query.first()
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):
flash('Styles LoRA directory not found.', 'error')
return redirect(url_for('styles_index'))
@@ -3877,7 +3874,7 @@ def bulk_create_styles_from_loras():
style_data['style_name'] = style_name
if 'lora' not in style_data: style_data['lora'] = {}
style_data['lora']['lora_name'] = f"Illustrious/Styles/{filename}"
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not style_data['lora'].get('lora_triggers'):
style_data['lora']['lora_triggers'] = name_base
@@ -4059,7 +4056,7 @@ def scene_detail(slug):
@app.route('/scene/<path:slug>/edit', methods=['GET', 'POST'])
def edit_scene(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
loras = get_available_scene_loras()
loras = get_available_loras('scenes')
if request.method == 'POST':
try:
@@ -4253,7 +4250,8 @@ def generate_scene_image(slug):
# Save preferences
session[f'char_scene_{slug}'] = character_slug
session[f'prefs_scene_{slug}'] = selected_fields
session.modified = True
# Build workflow using helper
workflow = _queue_scene_generation(scene_obj, character, selected_fields)
@@ -4285,20 +4283,20 @@ def save_scene_defaults(slug):
@app.route('/scene/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_scene_cover_from_preview(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_scene_{slug}')
if preview_path:
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
scene.image_path = preview_path
db.session.commit()
flash('Cover image updated from preview!')
flash('Cover image updated!')
else:
flash('No preview image available', 'error')
flash('No valid preview image selected.', 'error')
return redirect(url_for('scene_detail', slug=slug))
@app.route('/scenes/bulk_create', methods=['POST'])
def bulk_create_scenes_from_loras():
backgrounds_lora_dir = '/ImageModels/lora/Illustrious/Backgrounds/'
_s = Settings.query.first()
backgrounds_lora_dir = ((_s.lora_dir_scenes if _s else None) or '/ImageModels/lora/Illustrious/Backgrounds').rstrip('/')
_lora_subfolder = os.path.basename(backgrounds_lora_dir)
if not os.path.exists(backgrounds_lora_dir):
flash('Backgrounds LoRA directory not found.', 'error')
return redirect(url_for('scenes_index'))
@@ -4360,7 +4358,7 @@ def bulk_create_scenes_from_loras():
scene_data['scene_name'] = scene_name
if 'lora' not in scene_data: scene_data['lora'] = {}
scene_data['lora']['lora_name'] = f"Illustrious/Backgrounds/{filename}"
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
@@ -4554,7 +4552,7 @@ def detailer_detail(slug):
@app.route('/detailer/<path:slug>/edit', methods=['GET', 'POST'])
def edit_detailer(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
loras = get_available_detailer_loras()
loras = get_available_loras('detailers')
if request.method == 'POST':
try:
@@ -4712,8 +4710,8 @@ def generate_detailer_image(slug):
try:
# Get action type
action_type = request.form.get('action', 'preview')
action = request.form.get('action', 'preview')
# Get selected fields
selected_fields = request.form.getlist('include_field')
@@ -4737,13 +4735,14 @@ def generate_detailer_image(slug):
session[f'extra_pos_detailer_{slug}'] = extra_positive
session[f'extra_neg_detailer_{slug}'] = extra_negative
session[f'prefs_detailer_{slug}'] = selected_fields
session.modified = True
# Build workflow using helper
workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative)
char_label = character.name if character else 'no character'
label = f"Detailer: {detailer_obj.name} ({char_label}) {action_type}"
job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action_type))
label = f"Detailer: {detailer_obj.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
@@ -4769,15 +4768,13 @@ def save_detailer_defaults(slug):
@app.route('/detailer/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_detailer_cover_from_preview(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_detailer_{slug}')
if preview_path:
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
detailer.image_path = preview_path
db.session.commit()
flash('Cover image updated from preview!')
flash('Cover image updated!')
else:
flash('No preview image available', 'error')
flash('No valid preview image selected.', 'error')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/save_json', methods=['POST'])
@@ -4798,7 +4795,9 @@ def save_detailer_json(slug):
@app.route('/detailers/bulk_create', methods=['POST'])
def bulk_create_detailers_from_loras():
detailers_lora_dir = '/ImageModels/lora/Illustrious/Detailers/'
_s = Settings.query.first()
detailers_lora_dir = ((_s.lora_dir_detailers if _s else None) or '/ImageModels/lora/Illustrious/Detailers').rstrip('/')
_lora_subfolder = os.path.basename(detailers_lora_dir)
if not os.path.exists(detailers_lora_dir):
flash('Detailers LoRA directory not found.', 'error')
return redirect(url_for('detailers_index'))
@@ -4856,7 +4855,7 @@ def bulk_create_detailers_from_loras():
detailer_data['detailer_name'] = detailer_name
if 'lora' not in detailer_data: detailer_data['lora'] = {}
detailer_data['lora']['lora_name'] = f"Illustrious/Detailers/{filename}"
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
@@ -5092,6 +5091,7 @@ def generate_checkpoint_image(slug):
character_slug = character.slug
session[f'char_checkpoint_{slug}'] = character_slug
session.modified = True
workflow = _build_checkpoint_workflow(ckpt, character)
char_label = character.name if character else 'random'
@@ -5102,7 +5102,7 @@ def generate_checkpoint_image(slug):
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('checkpoint_detail', slug=slug))
except Exception as e:
print(f"Checkpoint generation error: {e}")
print(f"Generation error: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
@@ -5111,13 +5111,13 @@ def generate_checkpoint_image(slug):
@app.route('/checkpoint/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_checkpoint_cover_from_preview(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_checkpoint_{slug}')
if preview_path:
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
ckpt.image_path = preview_path
db.session.commit()
flash('Cover image updated from preview!')
flash('Cover image updated!')
else:
flash('No preview image available', 'error')
flash('No valid preview image selected.', 'error')
return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/save_json', methods=['POST'])
@@ -5281,7 +5281,7 @@ def look_detail(slug):
def edit_look(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
characters = Character.query.order_by(Character.name).all()
loras = get_available_loras()
loras = get_available_loras('characters')
if request.method == 'POST':
look.name = request.form.get('look_name', look.name)
@@ -5363,6 +5363,7 @@ def generate_look_image(slug):
session[f'prefs_look_{slug}'] = selected_fields
session[f'char_look_{slug}'] = character_slug
session.modified = True
lora_triggers = look.data.get('lora', {}).get('lora_triggers', '')
look_positive = look.data.get('positive', '')
@@ -5412,7 +5413,7 @@ def generate_look_image(slug):
return redirect(url_for('look_detail', slug=slug))
except Exception as e:
print(f"Look generation error: {e}")
print(f"Generation error: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
@@ -5421,13 +5422,13 @@ def generate_look_image(slug):
@app.route('/look/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_look_cover_from_preview(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_look_{slug}')
if preview_path:
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
look.image_path = preview_path
db.session.commit()
flash('Cover image updated from preview!')
flash('Cover image updated!')
else:
flash('No preview image available', 'error')
flash('No valid preview image selected.', 'error')
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/save_defaults', methods=['POST'])
@@ -5458,7 +5459,7 @@ def save_look_json(slug):
@app.route('/look/create', methods=['GET', 'POST'])
def create_look():
characters = Character.query.order_by(Character.name).all()
loras = get_available_loras()
loras = get_available_loras('characters')
if request.method == 'POST':
name = request.form.get('name', '').strip()
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
@@ -5513,7 +5514,9 @@ def clear_all_look_covers():
@app.route('/looks/bulk_create', methods=['POST'])
def bulk_create_looks_from_loras():
lora_dir = app.config['LORA_DIR']
_s = Settings.query.first()
lora_dir = ((_s.lora_dir_characters if _s else None) or app.config['LORA_DIR']).rstrip('/')
_lora_subfolder = os.path.basename(lora_dir)
if not os.path.exists(lora_dir):
flash('Looks LoRA directory not found.', 'error')
return redirect(url_for('looks_index'))
@@ -5574,7 +5577,7 @@ def bulk_create_looks_from_loras():
if 'lora' not in look_data:
look_data['lora'] = {}
look_data['lora']['lora_name'] = f"Illustrious/Looks/{filename}"
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:
@@ -6386,7 +6389,14 @@ if __name__ == '__main__':
columns_to_add = [
('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"),
('local_base_url', "VARCHAR(255)"),
('local_model', "VARCHAR(100)")
('local_model', "VARCHAR(100)"),
('lora_dir_characters', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Looks'"),
('lora_dir_outfits', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Clothing'"),
('lora_dir_actions', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Poses'"),
('lora_dir_styles', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Styles'"),
('lora_dir_scenes', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Backgrounds'"),
('lora_dir_detailers', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Detailers'"),
('checkpoint_dirs', "VARCHAR(1000) DEFAULT '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'"),
]
for col_name, col_type in columns_to_add:
try:

View File

@@ -132,6 +132,15 @@ class Settings(db.Model):
openrouter_model = db.Column(db.String(100), default='google/gemini-2.0-flash-001')
local_base_url = db.Column(db.String(255), nullable=True)
local_model = db.Column(db.String(100), nullable=True)
# LoRA directories (absolute paths on disk)
lora_dir_characters = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Looks')
lora_dir_outfits = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Clothing')
lora_dir_actions = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Poses')
lora_dir_styles = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Styles')
lora_dir_scenes = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Backgrounds')
lora_dir_detailers = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Detailers')
# Checkpoint scan directories (comma-separated list of absolute paths)
checkpoint_dirs = db.Column(db.String(1000), default='/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob')
def __repr__(self):
return '<Settings>'

View File

@@ -56,7 +56,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if action.image_path %}
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid" data-preview-path="{{ action.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -97,35 +97,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_action_cover_from_preview', slug=action.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_action_cover_from_preview', slug=action.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -163,7 +148,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -288,7 +273,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -314,20 +300,30 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -335,17 +331,9 @@
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') {
clearInterval(poll);
resolve(data);
} else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll);
reject(new Error(data.error || 'Job failed'));
} else if (data.status === 'processing') {
progressLabel.textContent = 'Generating…';
} else {
progressLabel.textContent = 'Queued…';
}
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -355,12 +343,10 @@
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
@@ -368,31 +354,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -400,7 +376,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -411,8 +387,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -427,43 +404,37 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentJobId = null;
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -474,10 +445,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
});

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Action Gallery</h2>
<h2>Action Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -47,7 +47,8 @@
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
{% if ckpt.image_path %}
<img src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}" class="img-fluid"
data-preview-path="{{ ckpt.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span>
@@ -89,39 +90,22 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.slug) }}" method="post" class="m-0">
<button type="submit" class="btn btn-sm btn-outline-light">Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
<small>Selected Preview</small>
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -131,7 +115,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('checkpoints_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('checkpoints_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -238,6 +222,7 @@
<img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="{{ img }}"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
</div>
@@ -259,9 +244,25 @@
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
let currentJobId = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -294,15 +295,8 @@
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
}
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
@@ -320,7 +314,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -329,6 +323,7 @@
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
title="${charName}">
@@ -347,36 +342,36 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
// Phase 1: submit all jobs immediately
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
const resp = await fetch(form.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
// Phase 2: poll all in parallel
batchLabel.textContent = `0 / ${pending.length} complete`;
let done = 0;
const total = pending.length;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -387,7 +382,7 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping after current submissions...';
});
// JSON Editor

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Checkpoint Gallery</h2>
<h2>Checkpoint Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -17,7 +17,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if character.image_path %}
<img src="{{ url_for('static', filename='uploads/' + character.image_path) }}" alt="{{ character.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + character.image_path) }}" alt="{{ character.name }}" class="img-fluid" data-preview-path="{{ character.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -44,35 +44,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_cover_from_preview', slug=character.slug) }}" method="post" class="m-0">
<button type="submit" class="btn btn-sm btn-outline-light">Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_cover_from_preview', slug=character.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -103,7 +88,7 @@
<h1 class="mb-0">{{ character.name }}</h1>
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
</div>
<a href="/" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="/" class="btn btn-outline-secondary">Back to Library</a>
</div>
<!-- Outfit Switcher -->
@@ -227,9 +212,27 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
// Clicking any image with data-preview-path selects it into the preview pane
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -254,55 +257,35 @@
}
form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') {
return;
}
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
// UI Reset
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST',
body: formData,
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
if (data.error) { alert('Error: ' + data.error); return; }
currentJobId = data.job_id;
progressLabel.textContent = 'Queued…';
// Wait for the background worker to finish
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
// Image is already saved — just display it
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
@@ -312,8 +295,7 @@
}
});
});
// Image modal function
function showImage(src) {
document.getElementById('modalImage').src = src;
}

View File

@@ -56,7 +56,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if detailer.image_path %}
<img src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}" class="img-fluid" data-preview-path="{{ detailer.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span>
@@ -121,35 +121,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
<small>Selected Preview</small>
<form action="{{ url_for('replace_detailer_cover_from_preview', slug=detailer.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<form action="{{ url_for('replace_detailer_cover_from_preview', slug=detailer.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -162,7 +147,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -257,7 +242,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -283,21 +269,31 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
const actionSelect = document.getElementById('action_select');
// Toggle character context info
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -307,8 +303,7 @@
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -318,9 +313,8 @@
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
@@ -330,26 +324,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -357,7 +346,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -368,8 +357,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -384,12 +374,13 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
@@ -398,26 +389,25 @@
fd.append('extra_negative', document.getElementById('extra_negative').value);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -428,10 +418,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
});

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Detailer Gallery</h2>
<h2>Detailer Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Gallery
<h4 class="mb-0">Image Gallery
<span class="text-muted fs-6 fw-normal ms-2">{{ total }} image{{ 's' if total != 1 else '' }}</span>
</h4>
</div>

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Character Gallery</h2>
<h2>Character Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -26,7 +26,7 @@
<div class="vr mx-1 d-none d-lg-block"></div>
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
<a href="/gallery" class="btn btn-sm btn-outline-light">Gallery</a>
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div>
<!-- Queue indicator -->
@@ -48,19 +48,6 @@
</div>
</nav>
<div class="default-checkpoint-bar border-bottom mb-4">
<div class="container d-flex align-items-center gap-2 py-2">
<small class="text-muted text-nowrap">Default checkpoint:</small>
<select id="defaultCheckpointSelect" class="form-select form-select-sm" style="max-width: 320px;">
<option value="">— workflow default —</option>
{% for ckpt in all_checkpoints %}
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
{% endfor %}
</select>
<small id="checkpointSaveStatus" class="text-muted" style="opacity:0;transition:opacity 0.5s">Saved</small>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
@@ -129,22 +116,44 @@
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
const ckptSelect = document.getElementById('defaultCheckpointSelect');
const saveStatus = document.getElementById('checkpointSaveStatus');
if (ckptSelect) {
ckptSelect.addEventListener('change', () => {
fetch('/set_default_checkpoint', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'checkpoint_path=' + encodeURIComponent(ckptSelect.value)
}).then(() => {
saveStatus.style.opacity = '1';
setTimeout(() => { saveStatus.style.opacity = '0'; }, 1500);
});
});
}
});
// ---- Loaded checkpoint → ComfyUI tooltip ----
(function() {
let _loadedCheckpoint = null;
async function pollLoadedCheckpoint() {
try {
const r = await fetch('/api/comfyui/loaded_checkpoint', { cache: 'no-store' });
const data = await r.json();
_loadedCheckpoint = data.checkpoint || null;
} catch {
_loadedCheckpoint = null;
}
updateComfyTooltip();
}
function updateComfyTooltip() {
const el = document.getElementById('status-comfyui');
if (!el) return;
const dot = el.querySelector('.status-dot');
const online = dot && dot.classList.contains('status-ok');
let text = 'ComfyUI: ' + (online ? 'online' : 'offline');
if (_loadedCheckpoint) {
const parts = _loadedCheckpoint.split(/[/\\]/);
const name = parts[parts.length - 1].replace(/\.safetensors$/, '');
text += '\n' + name;
}
el.setAttribute('data-bs-title', text);
el.setAttribute('title', text);
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) tip.setContent({ '.tooltip-inner': text });
}
// Hook into the existing status polling to refresh tooltip after status changes
window._updateComfyTooltip = updateComfyTooltip;
document.addEventListener('DOMContentLoaded', () => {
pollLoadedCheckpoint();
setInterval(pollLoadedCheckpoint, 30000);
});
})();
</script>
<script>
// ---- Resource delete modal (category galleries) ----
@@ -340,14 +349,15 @@
if (!el) return;
const dot = el.querySelector('.status-dot');
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
if (id === 'status-comfyui' && window._updateComfyTooltip) {
window._updateComfyTooltip();
return;
}
const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
el.setAttribute('data-bs-title', tooltipText);
el.setAttribute('title', tooltipText);
// Refresh tooltip instance if already initialised
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) {
tip.setContent({ '.tooltip-inner': tooltipText });
}
if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
}
async function pollService(svc) {

View File

@@ -43,9 +43,10 @@
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
{% if look.image_path %}
<img src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}" class="img-fluid"
data-preview-path="{{ look.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -89,35 +90,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_look_cover_from_preview', slug=look.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_look_cover_from_preview', slug=look.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -156,7 +142,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -237,9 +223,25 @@
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
let currentJobId = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -272,22 +274,15 @@
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
}
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
});
function showImage(src) {
document.getElementById('modalImage').src = src;
if (src) document.getElementById('modalImage').src = src;
}
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Looks Gallery</h2>
<h2>Looks Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -45,7 +45,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if outfit.image_path %}
<img src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}" class="img-fluid" data-preview-path="{{ outfit.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -86,35 +86,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_outfit_cover_from_preview', slug=outfit.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_outfit_cover_from_preview', slug=outfit.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -152,7 +137,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -245,7 +230,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -271,9 +257,25 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
let currentJobId = null;
let currentAction = null;
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
// Clicking any image with data-preview-path selects it into the preview pane
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -298,53 +300,34 @@
}
form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') {
return;
}
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
// UI Reset
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST',
body: formData,
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentJobId = data.job_id;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
@@ -366,7 +349,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -377,8 +360,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -393,43 +377,46 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
// Phase 1: submit all jobs immediately
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentJobId = null;
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
// Phase 2: poll all in parallel
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) {
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}
}).catch(err => {
done++;
console.error(`Failed for ${char.name}:`, err);
})
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -440,7 +427,7 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Outfit Gallery</h2>
<h2>Outfit Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -56,7 +56,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if scene.image_path %}
<img src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}" class="img-fluid" data-preview-path="{{ scene.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span>
@@ -100,35 +100,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
<small>Selected Preview</small>
<form action="{{ url_for('replace_scene_cover_from_preview', slug=scene.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<form action="{{ url_for('replace_scene_cover_from_preview', slug=scene.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -145,7 +130,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -255,7 +240,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -281,18 +267,30 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -302,8 +300,7 @@
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -324,26 +321,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -351,7 +343,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -362,8 +354,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -378,37 +371,37 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -419,10 +412,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
});

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Scene Gallery</h2>
<h2>Scene Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -73,6 +73,70 @@
</div>
</div>
<hr>
<!-- Directory Settings -->
<h5 class="mb-3 text-primary">LoRA Directories</h5>
<p class="text-muted small">Absolute paths on disk where LoRA files are scanned for each category.</p>
<div class="mb-3">
<label for="lora_dir_characters" class="form-label">Characters / Looks</label>
<input type="text" class="form-control" id="lora_dir_characters" name="lora_dir_characters"
value="{{ settings.lora_dir_characters or '/ImageModels/lora/Illustrious/Looks' }}">
</div>
<div class="mb-3">
<label for="lora_dir_outfits" class="form-label">Outfits</label>
<input type="text" class="form-control" id="lora_dir_outfits" name="lora_dir_outfits"
value="{{ settings.lora_dir_outfits or '/ImageModels/lora/Illustrious/Clothing' }}">
</div>
<div class="mb-3">
<label for="lora_dir_actions" class="form-label">Actions</label>
<input type="text" class="form-control" id="lora_dir_actions" name="lora_dir_actions"
value="{{ settings.lora_dir_actions or '/ImageModels/lora/Illustrious/Poses' }}">
</div>
<div class="mb-3">
<label for="lora_dir_styles" class="form-label">Styles</label>
<input type="text" class="form-control" id="lora_dir_styles" name="lora_dir_styles"
value="{{ settings.lora_dir_styles or '/ImageModels/lora/Illustrious/Styles' }}">
</div>
<div class="mb-3">
<label for="lora_dir_scenes" class="form-label">Scenes</label>
<input type="text" class="form-control" id="lora_dir_scenes" name="lora_dir_scenes"
value="{{ settings.lora_dir_scenes or '/ImageModels/lora/Illustrious/Backgrounds' }}">
</div>
<div class="mb-3">
<label for="lora_dir_detailers" class="form-label">Detailers</label>
<input type="text" class="form-control" id="lora_dir_detailers" name="lora_dir_detailers"
value="{{ settings.lora_dir_detailers or '/ImageModels/lora/Illustrious/Detailers' }}">
</div>
<hr>
<h5 class="mb-3 text-primary">Checkpoint Directories</h5>
<div class="mb-3">
<label for="checkpoint_dirs" class="form-label">Checkpoint Scan Paths</label>
<input type="text" class="form-control" id="checkpoint_dirs" name="checkpoint_dirs"
value="{{ settings.checkpoint_dirs or '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob' }}">
<div class="form-text">Comma-separated list of directories to scan for checkpoint files.</div>
</div>
<hr>
<h5 class="mb-3 text-primary">Default Checkpoint</h5>
<div class="mb-3">
<label for="default_checkpoint" class="form-label">Active Checkpoint</label>
<div class="input-group">
<select class="form-select" id="default_checkpoint">
<option value="">— workflow default —</option>
{% for ckpt in all_checkpoints %}
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
{% endfor %}
</select>
<span id="ckpt-save-status" class="input-group-text text-success" style="opacity:0;transition:opacity 0.5s">Saved</span>
</div>
<div class="form-text">Sets the checkpoint used for all generation requests. Saved immediately on change.</div>
</div>
<div class="d-grid mt-4">
<button type="submit" class="btn btn-primary btn-lg">Save All Settings</button>
</div>
@@ -152,6 +216,22 @@
}
});
// Default Checkpoint
const defaultCkptSelect = document.getElementById('default_checkpoint');
const ckptSaveStatus = document.getElementById('ckpt-save-status');
if (defaultCkptSelect) {
defaultCkptSelect.addEventListener('change', () => {
fetch('/set_default_checkpoint', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'checkpoint_path=' + encodeURIComponent(defaultCkptSelect.value)
}).then(() => {
ckptSaveStatus.style.opacity = '1';
setTimeout(() => { ckptSaveStatus.style.opacity = '0'; }, 1500);
});
});
}
// Local Model Loading
const connectLocalBtn = document.getElementById('connect-local-btn');
const localModelSelect = document.getElementById('local_model');

View File

@@ -56,7 +56,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if style.image_path %}
<img src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}" class="img-fluid" data-preview-path="{{ style.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span>
@@ -100,35 +100,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
<small>Selected Preview</small>
<form action="{{ url_for('replace_style_cover_from_preview', slug=style.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<form action="{{ url_for('replace_style_cover_from_preview', slug=style.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -145,7 +130,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -247,7 +232,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -273,20 +259,30 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -296,8 +292,7 @@
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -307,9 +302,8 @@
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
@@ -319,26 +313,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -346,7 +335,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -357,8 +346,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -373,37 +363,37 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -414,10 +404,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
});

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Style Gallery</h2>
<h2>Style Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>