5 Commits

Author SHA1 Message Date
Aodhan Collins
2c1c3a7ed7 Merge branch 'presets'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:09:00 +00:00
Aodhan Collins
ee36caccd6 Sort all batch generation queues by JSON filename
All get_missing_* routes and generate_missing routes now order results
by filename (alphabetical) instead of display name or undefined order.
Checkpoint uses checkpoint_path as the equivalent sort key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:08:37 +00:00
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 803 additions and 795 deletions

394
app.py
View File

@@ -1,5 +1,6 @@
import os import os
import json import json
import logging
import time import time
import re import re
import requests import requests
@@ -42,6 +43,16 @@ app.config['SESSION_PERMANENT'] = False
db.init_app(app) db.init_app(app)
Session(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 # Generation Job Queue
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -78,6 +89,7 @@ def _enqueue_job(label, workflow, finalize_fn):
with _job_queue_lock: with _job_queue_lock:
_job_queue.append(job) _job_queue.append(job)
_job_history[job['id']] = job _job_history[job['id']] = job
logger.info("Job queued: [%s] %s", job['id'][:8], label)
_queue_worker_event.set() _queue_worker_event.set()
return job return job
@@ -104,6 +116,8 @@ def _queue_worker():
with _job_queue_lock: with _job_queue_lock:
job['status'] = 'processing' job['status'] = 'processing'
logger.info("Job started: [%s] %s", job['id'][:8], job['label'])
try: try:
with app.app_context(): with app.app_context():
# Send workflow to ComfyUI # Send workflow to ComfyUI
@@ -114,6 +128,7 @@ def _queue_worker():
comfy_id = prompt_response['prompt_id'] comfy_id = prompt_response['prompt_id']
with _job_queue_lock: with _job_queue_lock:
job['comfy_prompt_id'] = comfy_id 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) # Poll until done (max ~10 minutes)
max_retries = 300 max_retries = 300
@@ -129,15 +144,17 @@ def _queue_worker():
if not finished: if not finished:
raise Exception("ComfyUI generation timed out") 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) # Run the finalize callback (saves image to disk / DB)
# finalize_fn(comfy_prompt_id, job) — job is passed so callback can store result # finalize_fn(comfy_prompt_id, job) — job is passed so callback can store result
job['finalize_fn'](comfy_id, job) job['finalize_fn'](comfy_id, job)
with _job_queue_lock: with _job_queue_lock:
job['status'] = 'done' job['status'] = 'done'
logger.info("Job done: [%s] %s", job['id'][:8], job['label'])
except Exception as e: 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: with _job_queue_lock:
job['status'] = 'failed' job['status'] = 'failed'
job['error'] = str(e) job['error'] = str(e)
@@ -385,6 +402,7 @@ def inject_default_checkpoint():
@app.route('/set_default_checkpoint', methods=['POST']) @app.route('/set_default_checkpoint', methods=['POST'])
def set_default_checkpoint(): def set_default_checkpoint():
session['default_checkpoint'] = request.form.get('checkpoint_path', '') session['default_checkpoint'] = request.form.get('checkpoint_path', '')
session.modified = True
return {'status': 'ok'} return {'status': 'ok'}
@@ -401,6 +419,27 @@ def api_status_comfyui():
return {'status': 'error'} 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') @app.route('/api/status/mcp')
def api_status_mcp(): def api_status_mcp():
"""Return whether the danbooru-mcp Docker container is running.""" """Return whether the danbooru-mcp Docker container is running."""
@@ -417,80 +456,39 @@ def api_status_mcp():
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
def get_available_loras(): _LORA_DEFAULTS = {
loras = [] 'characters': '/ImageModels/lora/Illustrious/Looks',
if os.path.exists(app.config['LORA_DIR']): 'outfits': '/ImageModels/lora/Illustrious/Clothing',
for f in os.listdir(app.config['LORA_DIR']): 'actions': '/ImageModels/lora/Illustrious/Poses',
if f.endswith('.safetensors'): 'styles': '/ImageModels/lora/Illustrious/Styles',
# Using the format seen in character JSONs 'scenes': '/ImageModels/lora/Illustrious/Backgrounds',
loras.append(f"Illustrious/Looks/{f}") 'detailers': '/ImageModels/lora/Illustrious/Detailers',
return sorted(loras) }
def get_available_clothing_loras(): def get_available_loras(category):
"""Get LoRAs from the Clothing directory for outfit LoRAs.""" """Return sorted list of LoRA paths for the given category.
clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/' category: one of 'characters','outfits','actions','styles','scenes','detailers'
loras = [] """
if os.path.exists(clothing_lora_dir): settings = Settings.query.first()
for f in os.listdir(clothing_lora_dir): lora_dir = (getattr(settings, f'lora_dir_{category}', None) if settings else None) or _LORA_DEFAULTS.get(category, '')
if f.endswith('.safetensors'): if not lora_dir or not os.path.isdir(lora_dir):
loras.append(f"Illustrious/Clothing/{f}") return []
return sorted(loras) 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_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_checkpoints(): 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 = [] checkpoints = []
for ckpt_dir in checkpoint_dirs_str.split(','):
# Scan Illustrious ckpt_dir = ckpt_dir.strip()
if os.path.exists(app.config['ILLUSTRIOUS_MODELS_DIR']): if not ckpt_dir or not os.path.isdir(ckpt_dir):
for f in os.listdir(app.config['ILLUSTRIOUS_MODELS_DIR']): continue
prefix = os.path.basename(ckpt_dir.rstrip('/'))
for f in os.listdir(ckpt_dir):
if f.endswith('.safetensors') or f.endswith('.ckpt'): if f.endswith('.safetensors') or f.endswith('.ckpt'):
checkpoints.append(f"Illustrious/{f}") checkpoints.append(f"{prefix}/{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}")
return sorted(checkpoints) return sorted(checkpoints)
def allowed_file(filename): def allowed_file(filename):
@@ -1484,10 +1482,17 @@ def settings():
settings.openrouter_model = request.form.get('model') settings.openrouter_model = request.form.get('model')
settings.local_base_url = request.form.get('local_base_url') settings.local_base_url = request.form.get('local_base_url')
settings.local_model = request.form.get('local_model') 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() db.session.commit()
flash('Settings updated successfully!') flash('Settings updated successfully!')
return redirect(url_for('settings')) return redirect(url_for('settings'))
return render_template('settings.html', settings=settings) return render_template('settings.html', settings=settings)
@app.route('/') @app.route('/')
@@ -1823,7 +1828,7 @@ def create_character():
@app.route('/character/<path:slug>/edit', methods=['GET', 'POST']) @app.route('/character/<path:slug>/edit', methods=['GET', 'POST'])
def edit_character(slug): def edit_character(slug):
character = Character.query.filter_by(slug=slug).first_or_404() 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() char_looks = Look.query.filter_by(character_id=character.character_id).order_by(Look.name).all()
if request.method == 'POST': if request.method == 'POST':
@@ -2080,28 +2085,18 @@ def upload_image(slug):
@app.route('/character/<path:slug>/replace_cover_from_preview', methods=['POST']) @app.route('/character/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_cover_from_preview(slug): def replace_cover_from_preview(slug):
character = Character.query.filter_by(slug=slug).first_or_404() character = Character.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_{slug}') preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
if preview_path:
character.image_path = preview_path character.image_path = preview_path
db.session.commit() db.session.commit()
flash('Cover image updated from preview!') flash('Cover image updated!')
else: else:
flash('No preview image available', 'error') flash('No valid preview image selected.', 'error')
return redirect(url_for('detail', slug=slug)) return redirect(url_for('detail', slug=slug))
def _log_workflow_prompts(label, workflow): 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 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 = [] active_loras = []
for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]: for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]:
if node_id in workflow: if node_id in workflow:
@@ -2109,16 +2104,26 @@ def _log_workflow_prompts(label, workflow):
if name: if name:
w = workflow[node_id]["inputs"].get("strength_model", "?") 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}") 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', '') face_text = workflow.get('14', {}).get('inputs', {}).get('text', '')
hand_text = workflow.get('15', {}).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: if face_text:
print(f" [F] Face : {face_text}") lines.append(f" [F] Face : {face_text}")
if hand_text: if hand_text:
print(f" [H] Hand : {hand_text}") lines.append(f" [H] Hand : {hand_text}")
print(f"{sep}\n") 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): 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 workflow["16"]["inputs"]["clip"] = ["4", 1] # From checkpoint
model_source = ["16", 0] model_source = ["16", 0]
clip_source = ["16", 1] 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 (Node 17) - chains from character LoRA or checkpoint
outfit_lora_data = outfit.data.get('lora', {}) if outfit else {} 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 workflow["17"]["inputs"]["clip"] = clip_source
model_source = ["17", 0] model_source = ["17", 0]
clip_source = ["17", 1] 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 (Node 18) - chains from previous LoRA or checkpoint
action_lora_data = action.data.get('lora', {}) if action else {} 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 workflow["18"]["inputs"]["clip"] = clip_source
model_source = ["18", 0] model_source = ["18", 0]
clip_source = ["18", 1] 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 # Style/Detailer/Scene LoRA (Node 19) - chains from previous LoRA or checkpoint
# Priority: Style > Detailer > Scene (Scene LoRAs are rare but supported) # 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 workflow["19"]["inputs"]["clip"] = clip_source
model_source = ["19", 0] model_source = ["19", 0]
clip_source = ["19", 1] 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 # Apply connections to all model/clip consumers
workflow["3"]["inputs"]["model"] = model_source workflow["3"]["inputs"]["model"] = model_source
@@ -2278,22 +2283,9 @@ def _get_default_checkpoint():
return None, None return None, None
return ckpt.checkpoint_path, ckpt.data or {} 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') @app.route('/get_missing_characters')
def get_missing_characters(): def get_missing_characters():
missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).all() missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.filename).all()
return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]}
@app.route('/clear_all_covers', methods=['POST']) @app.route('/clear_all_covers', methods=['POST'])
@@ -2308,7 +2300,7 @@ def clear_all_covers():
def generate_missing(): def generate_missing():
missing = Character.query.filter( missing = Character.query.filter(
(Character.image_path == None) | (Character.image_path == '') (Character.image_path == None) | (Character.image_path == '')
).order_by(Character.name).all() ).order_by(Character.filename).all()
if not missing: if not missing:
flash("No characters missing cover images.") flash("No characters missing cover images.")
@@ -2345,7 +2337,8 @@ def generate_image(slug):
# Save preferences # Save preferences
session[f'prefs_{slug}'] = selected_fields session[f'prefs_{slug}'] = selected_fields
session.modified = True
# Build workflow # Build workflow
with open('comfy_workflow.json', 'r') as f: with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f) workflow = json.load(f)
@@ -2353,6 +2346,7 @@ def generate_image(slug):
ckpt_path, ckpt_data = _get_default_checkpoint() ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) 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)) job = _enqueue_job(label, workflow, _make_finalize('characters', slug, Character, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']} return {'status': 'queued', 'job_id': job['id']}
@@ -2376,7 +2370,7 @@ def save_defaults(slug):
@app.route('/get_missing_outfits') @app.route('/get_missing_outfits')
def get_missing_outfits(): def get_missing_outfits():
missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).all() missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.filename).all()
return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]} return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]}
@app.route('/clear_all_outfit_covers', methods=['POST']) @app.route('/clear_all_outfit_covers', methods=['POST'])
@@ -2389,7 +2383,7 @@ def clear_all_outfit_covers():
@app.route('/get_missing_actions') @app.route('/get_missing_actions')
def get_missing_actions(): def get_missing_actions():
missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).all() missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.filename).all()
return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]} return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]}
@app.route('/clear_all_action_covers', methods=['POST']) @app.route('/clear_all_action_covers', methods=['POST'])
@@ -2402,7 +2396,7 @@ def clear_all_action_covers():
@app.route('/get_missing_scenes') @app.route('/get_missing_scenes')
def get_missing_scenes(): def get_missing_scenes():
missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).all() missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.filename).all()
return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]}
@app.route('/clear_all_scene_covers', methods=['POST']) @app.route('/clear_all_scene_covers', methods=['POST'])
@@ -2428,7 +2422,9 @@ def rescan_outfits():
@app.route('/outfits/bulk_create', methods=['POST']) @app.route('/outfits/bulk_create', methods=['POST'])
def bulk_create_outfits_from_loras(): 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): if not os.path.exists(clothing_lora_dir):
flash('Clothing LoRA directory not found.', 'error') flash('Clothing LoRA directory not found.', 'error')
return redirect(url_for('outfits_index')) return redirect(url_for('outfits_index'))
@@ -2489,7 +2485,7 @@ def bulk_create_outfits_from_loras():
if 'lora' not in outfit_data: if 'lora' not in outfit_data:
outfit_data['lora'] = {} 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'): if not outfit_data['lora'].get('lora_triggers'):
outfit_data['lora']['lora_triggers'] = name_base outfit_data['lora']['lora_triggers'] = name_base
if outfit_data['lora'].get('lora_weight') is None: 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']) @app.route('/outfit/<path:slug>/edit', methods=['GET', 'POST'])
def edit_outfit(slug): def edit_outfit(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404() 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': if request.method == 'POST':
try: try:
@@ -2667,7 +2663,8 @@ def generate_outfit_image(slug):
# Save preferences # Save preferences
session[f'prefs_outfit_{slug}'] = selected_fields session[f'prefs_outfit_{slug}'] = selected_fields
session[f'char_outfit_{slug}'] = character_slug session[f'char_outfit_{slug}'] = character_slug
session.modified = True
# Build combined data for prompt building # Build combined data for prompt building
if character: if character:
# Combine character identity/defaults with outfit wardrobe # 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']) @app.route('/outfit/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_outfit_cover_from_preview(slug): def replace_outfit_cover_from_preview(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404() outfit = Outfit.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_outfit_{slug}') preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
if preview_path:
outfit.image_path = preview_path outfit.image_path = preview_path
db.session.commit() db.session.commit()
flash('Cover image updated from preview!') flash('Cover image updated!')
else: else:
flash('No preview image available', 'error') flash('No valid preview image selected.', 'error')
return redirect(url_for('outfit_detail', slug=slug)) return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/create', methods=['GET', 'POST']) @app.route('/outfit/create', methods=['GET', 'POST'])
@@ -2988,7 +2983,7 @@ def action_detail(slug):
@app.route('/action/<path:slug>/edit', methods=['GET', 'POST']) @app.route('/action/<path:slug>/edit', methods=['GET', 'POST'])
def edit_action(slug): def edit_action(slug):
action = Action.query.filter_by(slug=slug).first_or_404() action = Action.query.filter_by(slug=slug).first_or_404()
loras = get_available_action_loras() loras = get_available_loras('actions')
if request.method == 'POST': if request.method == 'POST':
try: try:
@@ -3091,7 +3086,7 @@ def generate_action_image(slug):
try: try:
# Get action type # Get action type
action_type = request.form.get('action', 'preview') action = request.form.get('action', 'preview')
# Get selected fields # Get selected fields
selected_fields = request.form.getlist('include_field') selected_fields = request.form.getlist('include_field')
@@ -3107,7 +3102,8 @@ def generate_action_image(slug):
# Save preferences # Save preferences
session[f'char_action_{slug}'] = character_slug session[f'char_action_{slug}'] = character_slug
session[f'prefs_action_{slug}'] = selected_fields session[f'prefs_action_{slug}'] = selected_fields
session.modified = True
# Build combined data for prompt building # Build combined data for prompt building
if character: if character:
# Combine character identity/wardrobe with action details # 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) 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' char_label = character.name if character else 'no character'
label = f"Action: {action_obj.name} ({char_label}) {action_type}" label = f"Action: {action_obj.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action_type)) job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']} 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']) @app.route('/action/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_action_cover_from_preview(slug): def replace_action_cover_from_preview(slug):
action = Action.query.filter_by(slug=slug).first_or_404() action = Action.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_action_{slug}') preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
if preview_path:
action.image_path = preview_path action.image_path = preview_path
db.session.commit() db.session.commit()
flash('Cover image updated from preview!') flash('Cover image updated!')
else: else:
flash('No preview image available', 'error') flash('No valid preview image selected.', 'error')
return redirect(url_for('action_detail', slug=slug)) return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/save_defaults', methods=['POST']) @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']) @app.route('/actions/bulk_create', methods=['POST'])
def bulk_create_actions_from_loras(): 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): if not os.path.exists(actions_lora_dir):
flash('Actions LoRA directory not found.', 'error') flash('Actions LoRA directory not found.', 'error')
return redirect(url_for('actions_index')) return redirect(url_for('actions_index'))
@@ -3348,7 +3344,7 @@ def bulk_create_actions_from_loras():
# Update lora dict safely # Update lora dict safely
if 'lora' not in action_data: action_data['lora'] = {} 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 # Fallbacks if LLM failed to extract metadata
if not action_data['lora'].get('lora_triggers'): 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']) @app.route('/style/<path:slug>/edit', methods=['GET', 'POST'])
def edit_style(slug): def edit_style(slug):
style = Style.query.filter_by(slug=slug).first_or_404() style = Style.query.filter_by(slug=slug).first_or_404()
loras = get_available_style_loras() loras = get_available_loras('styles')
if request.method == 'POST': if request.method == 'POST':
try: try:
@@ -3718,7 +3714,8 @@ def generate_style_image(slug):
# Save preferences # Save preferences
session[f'char_style_{slug}'] = character_slug session[f'char_style_{slug}'] = character_slug
session[f'prefs_style_{slug}'] = selected_fields session[f'prefs_style_{slug}'] = selected_fields
session.modified = True
# Build workflow using helper (returns workflow dict, not prompt_response) # Build workflow using helper (returns workflow dict, not prompt_response)
workflow = _build_style_workflow(style_obj, character, selected_fields) workflow = _build_style_workflow(style_obj, character, selected_fields)
@@ -3750,25 +3747,23 @@ def save_style_defaults(slug):
@app.route('/style/<path:slug>/replace_cover_from_preview', methods=['POST']) @app.route('/style/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_style_cover_from_preview(slug): def replace_style_cover_from_preview(slug):
style = Style.query.filter_by(slug=slug).first_or_404() style = Style.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_style_{slug}') preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
if preview_path:
style.image_path = preview_path style.image_path = preview_path
db.session.commit() db.session.commit()
flash('Cover image updated from preview!') flash('Cover image updated!')
else: else:
flash('No preview image available', 'error') flash('No valid preview image selected.', 'error')
return redirect(url_for('style_detail', slug=slug)) return redirect(url_for('style_detail', slug=slug))
@app.route('/get_missing_styles') @app.route('/get_missing_styles')
def get_missing_styles(): def get_missing_styles():
missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).all() missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.filename).all()
return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]}
@app.route('/get_missing_detailers') @app.route('/get_missing_detailers')
def get_missing_detailers(): def get_missing_detailers():
missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).all() missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.filename).all()
return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]} return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]}
@app.route('/clear_all_detailer_covers', methods=['POST']) @app.route('/clear_all_detailer_covers', methods=['POST'])
@@ -3791,7 +3786,7 @@ def clear_all_style_covers():
def generate_missing_styles(): def generate_missing_styles():
missing = Style.query.filter( missing = Style.query.filter(
(Style.image_path == None) | (Style.image_path == '') (Style.image_path == None) | (Style.image_path == '')
).order_by(Style.name).all() ).order_by(Style.filename).all()
if not missing: if not missing:
flash("No styles missing cover images.") flash("No styles missing cover images.")
@@ -3819,7 +3814,9 @@ def generate_missing_styles():
@app.route('/styles/bulk_create', methods=['POST']) @app.route('/styles/bulk_create', methods=['POST'])
def bulk_create_styles_from_loras(): 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): if not os.path.exists(styles_lora_dir):
flash('Styles LoRA directory not found.', 'error') flash('Styles LoRA directory not found.', 'error')
return redirect(url_for('styles_index')) return redirect(url_for('styles_index'))
@@ -3877,7 +3874,7 @@ def bulk_create_styles_from_loras():
style_data['style_name'] = style_name style_data['style_name'] = style_name
if 'lora' not in style_data: style_data['lora'] = {} 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'): if not style_data['lora'].get('lora_triggers'):
style_data['lora']['lora_triggers'] = name_base style_data['lora']['lora_triggers'] = name_base
@@ -4059,7 +4056,7 @@ def scene_detail(slug):
@app.route('/scene/<path:slug>/edit', methods=['GET', 'POST']) @app.route('/scene/<path:slug>/edit', methods=['GET', 'POST'])
def edit_scene(slug): def edit_scene(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404() scene = Scene.query.filter_by(slug=slug).first_or_404()
loras = get_available_scene_loras() loras = get_available_loras('scenes')
if request.method == 'POST': if request.method == 'POST':
try: try:
@@ -4253,7 +4250,8 @@ def generate_scene_image(slug):
# Save preferences # Save preferences
session[f'char_scene_{slug}'] = character_slug session[f'char_scene_{slug}'] = character_slug
session[f'prefs_scene_{slug}'] = selected_fields session[f'prefs_scene_{slug}'] = selected_fields
session.modified = True
# Build workflow using helper # Build workflow using helper
workflow = _queue_scene_generation(scene_obj, character, selected_fields) 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']) @app.route('/scene/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_scene_cover_from_preview(slug): def replace_scene_cover_from_preview(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404() scene = Scene.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_scene_{slug}') preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
if preview_path:
scene.image_path = preview_path scene.image_path = preview_path
db.session.commit() db.session.commit()
flash('Cover image updated from preview!') flash('Cover image updated!')
else: else:
flash('No preview image available', 'error') flash('No valid preview image selected.', 'error')
return redirect(url_for('scene_detail', slug=slug)) return redirect(url_for('scene_detail', slug=slug))
@app.route('/scenes/bulk_create', methods=['POST']) @app.route('/scenes/bulk_create', methods=['POST'])
def bulk_create_scenes_from_loras(): 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): if not os.path.exists(backgrounds_lora_dir):
flash('Backgrounds LoRA directory not found.', 'error') flash('Backgrounds LoRA directory not found.', 'error')
return redirect(url_for('scenes_index')) return redirect(url_for('scenes_index'))
@@ -4360,7 +4358,7 @@ def bulk_create_scenes_from_loras():
scene_data['scene_name'] = scene_name scene_data['scene_name'] = scene_name
if 'lora' not in scene_data: scene_data['lora'] = {} 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'): if not scene_data['lora'].get('lora_triggers'):
scene_data['lora']['lora_triggers'] = name_base scene_data['lora']['lora_triggers'] = name_base
@@ -4554,7 +4552,7 @@ def detailer_detail(slug):
@app.route('/detailer/<path:slug>/edit', methods=['GET', 'POST']) @app.route('/detailer/<path:slug>/edit', methods=['GET', 'POST'])
def edit_detailer(slug): def edit_detailer(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404() detailer = Detailer.query.filter_by(slug=slug).first_or_404()
loras = get_available_detailer_loras() loras = get_available_loras('detailers')
if request.method == 'POST': if request.method == 'POST':
try: try:
@@ -4712,8 +4710,8 @@ def generate_detailer_image(slug):
try: try:
# Get action type # Get action type
action_type = request.form.get('action', 'preview') action = request.form.get('action', 'preview')
# Get selected fields # Get selected fields
selected_fields = request.form.getlist('include_field') 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_pos_detailer_{slug}'] = extra_positive
session[f'extra_neg_detailer_{slug}'] = extra_negative session[f'extra_neg_detailer_{slug}'] = extra_negative
session[f'prefs_detailer_{slug}'] = selected_fields session[f'prefs_detailer_{slug}'] = selected_fields
session.modified = True
# Build workflow using helper # Build workflow using helper
workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative) 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' char_label = character.name if character else 'no character'
label = f"Detailer: {detailer_obj.name} ({char_label}) {action_type}" label = f"Detailer: {detailer_obj.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action_type)) job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action))
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']} 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']) @app.route('/detailer/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_detailer_cover_from_preview(slug): def replace_detailer_cover_from_preview(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404() detailer = Detailer.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_detailer_{slug}') preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
if preview_path:
detailer.image_path = preview_path detailer.image_path = preview_path
db.session.commit() db.session.commit()
flash('Cover image updated from preview!') flash('Cover image updated!')
else: else:
flash('No preview image available', 'error') flash('No valid preview image selected.', 'error')
return redirect(url_for('detailer_detail', slug=slug)) return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/save_json', methods=['POST']) @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']) @app.route('/detailers/bulk_create', methods=['POST'])
def bulk_create_detailers_from_loras(): 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): if not os.path.exists(detailers_lora_dir):
flash('Detailers LoRA directory not found.', 'error') flash('Detailers LoRA directory not found.', 'error')
return redirect(url_for('detailers_index')) return redirect(url_for('detailers_index'))
@@ -4856,7 +4855,7 @@ def bulk_create_detailers_from_loras():
detailer_data['detailer_name'] = detailer_name detailer_data['detailer_name'] = detailer_name
if 'lora' not in detailer_data: detailer_data['lora'] = {} 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'): if not detailer_data['lora'].get('lora_triggers'):
detailer_data['lora']['lora_triggers'] = name_base detailer_data['lora']['lora_triggers'] = name_base
@@ -5092,6 +5091,7 @@ def generate_checkpoint_image(slug):
character_slug = character.slug character_slug = character.slug
session[f'char_checkpoint_{slug}'] = character_slug session[f'char_checkpoint_{slug}'] = character_slug
session.modified = True
workflow = _build_checkpoint_workflow(ckpt, character) workflow = _build_checkpoint_workflow(ckpt, character)
char_label = character.name if character else 'random' 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 {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('checkpoint_detail', slug=slug)) return redirect(url_for('checkpoint_detail', slug=slug))
except Exception as e: except Exception as e:
print(f"Checkpoint generation error: {e}") print(f"Generation error: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500 return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}") 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']) @app.route('/checkpoint/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_checkpoint_cover_from_preview(slug): def replace_checkpoint_cover_from_preview(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_checkpoint_{slug}') preview_path = request.form.get('preview_path')
if preview_path: if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
ckpt.image_path = preview_path ckpt.image_path = preview_path
db.session.commit() db.session.commit()
flash('Cover image updated from preview!') flash('Cover image updated!')
else: else:
flash('No preview image available', 'error') flash('No valid preview image selected.', 'error')
return redirect(url_for('checkpoint_detail', slug=slug)) return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/save_json', methods=['POST']) @app.route('/checkpoint/<path:slug>/save_json', methods=['POST'])
@@ -5138,7 +5138,7 @@ def save_checkpoint_json(slug):
@app.route('/get_missing_checkpoints') @app.route('/get_missing_checkpoints')
def get_missing_checkpoints(): def get_missing_checkpoints():
missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).all() missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.checkpoint_path).all()
return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]}
@app.route('/clear_all_checkpoint_covers', methods=['POST']) @app.route('/clear_all_checkpoint_covers', methods=['POST'])
@@ -5281,7 +5281,7 @@ def look_detail(slug):
def edit_look(slug): def edit_look(slug):
look = Look.query.filter_by(slug=slug).first_or_404() look = Look.query.filter_by(slug=slug).first_or_404()
characters = Character.query.order_by(Character.name).all() characters = Character.query.order_by(Character.name).all()
loras = get_available_loras() loras = get_available_loras('characters')
if request.method == 'POST': if request.method == 'POST':
look.name = request.form.get('look_name', look.name) 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'prefs_look_{slug}'] = selected_fields
session[f'char_look_{slug}'] = character_slug session[f'char_look_{slug}'] = character_slug
session.modified = True
lora_triggers = look.data.get('lora', {}).get('lora_triggers', '') lora_triggers = look.data.get('lora', {}).get('lora_triggers', '')
look_positive = look.data.get('positive', '') look_positive = look.data.get('positive', '')
@@ -5412,7 +5413,7 @@ def generate_look_image(slug):
return redirect(url_for('look_detail', slug=slug)) return redirect(url_for('look_detail', slug=slug))
except Exception as e: except Exception as e:
print(f"Look generation error: {e}") print(f"Generation error: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500 return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}") 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']) @app.route('/look/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_look_cover_from_preview(slug): def replace_look_cover_from_preview(slug):
look = Look.query.filter_by(slug=slug).first_or_404() look = Look.query.filter_by(slug=slug).first_or_404()
preview_path = session.get(f'preview_look_{slug}') preview_path = request.form.get('preview_path')
if preview_path: if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
look.image_path = preview_path look.image_path = preview_path
db.session.commit() db.session.commit()
flash('Cover image updated from preview!') flash('Cover image updated!')
else: else:
flash('No preview image available', 'error') flash('No valid preview image selected.', 'error')
return redirect(url_for('look_detail', slug=slug)) return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/save_defaults', methods=['POST']) @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']) @app.route('/look/create', methods=['GET', 'POST'])
def create_look(): def create_look():
characters = Character.query.order_by(Character.name).all() characters = Character.query.order_by(Character.name).all()
loras = get_available_loras() loras = get_available_loras('characters')
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_')) look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
@@ -5500,7 +5501,7 @@ def create_look():
@app.route('/get_missing_looks') @app.route('/get_missing_looks')
def get_missing_looks(): def get_missing_looks():
missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).all() missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.filename).all()
return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]} return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]}
@app.route('/clear_all_look_covers', methods=['POST']) @app.route('/clear_all_look_covers', methods=['POST'])
@@ -5513,7 +5514,9 @@ def clear_all_look_covers():
@app.route('/looks/bulk_create', methods=['POST']) @app.route('/looks/bulk_create', methods=['POST'])
def bulk_create_looks_from_loras(): 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): if not os.path.exists(lora_dir):
flash('Looks LoRA directory not found.', 'error') flash('Looks LoRA directory not found.', 'error')
return redirect(url_for('looks_index')) return redirect(url_for('looks_index'))
@@ -5574,7 +5577,7 @@ def bulk_create_looks_from_loras():
if 'lora' not in look_data: if 'lora' not in look_data:
look_data['lora'] = {} 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'): if not look_data['lora'].get('lora_triggers'):
look_data['lora']['lora_triggers'] = name_base look_data['lora']['lora_triggers'] = name_base
if look_data['lora'].get('lora_weight') is None: if look_data['lora'].get('lora_weight') is None:
@@ -6386,7 +6389,14 @@ if __name__ == '__main__':
columns_to_add = [ columns_to_add = [
('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"), ('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"),
('local_base_url', "VARCHAR(255)"), ('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: for col_name, col_type in columns_to_add:
try: 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') 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_base_url = db.Column(db.String(255), nullable=True)
local_model = db.Column(db.String(100), 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): def __repr__(self):
return '<Settings>' return '<Settings>'

View File

@@ -56,7 +56,7 @@
<div class="card mb-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').src)">
{% if action.image_path %} {% 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 %} {% else %}
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
{% endif %} {% endif %}
@@ -97,35 +97,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center"> <span>Selected Preview</span>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <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)"> <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> </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 mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -163,7 +148,7 @@
</div> </div>
<div class="d-flex gap-2"> <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> <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>
</div> </div>
@@ -288,7 +273,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <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 progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context'); const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => { charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') { charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
}); });
let currentJobId = null; function selectPreview(relativePath, imageUrl) {
let currentAction = null; 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) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -335,17 +331,9 @@
try { try {
const resp = await fetch(`/api/queue/${jobId}/status`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'done') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
clearInterval(poll); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
resolve(data); else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} 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…';
}
} catch (err) { console.error('Poll error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 1500); }, 1500);
}); });
@@ -355,12 +343,10 @@
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…'; progressLabel.textContent = 'Queuing…';
try { try {
@@ -368,31 +354,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); 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…';
const jobResult = await waitForJob(currentJobId); const jobResult = await waitForJob(data.job_id);
currentJobId = null; if (jobResult.result?.image_url) {
if (jobResult.result && jobResult.result.image_url) { selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
previewImg.src = jobResult.result.image_url; addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
} }
} catch (err) { } catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
console.error(err); finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
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 = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -400,7 +376,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -411,8 +387,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -427,43 +404,37 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); 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; 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(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
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 data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
currentJobId = data.job_id; } catch (err) { console.error(`Submit error for ${char.name}:`, err); }
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');
}
} }
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%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;
@@ -474,10 +445,9 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); 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) }}'); initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
}); });

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="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> <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" data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')"> onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
{% if ckpt.image_path %} {% 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 %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
@@ -89,39 +90,22 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2"> <small>Selected Preview</small>
<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>
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.slug) }}" method="post" class="m-0" id="replace-cover-form"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img').src)"> onclick="showImage(this.querySelector('img') ? 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> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
@@ -131,7 +115,7 @@
</div> </div>
<div class="d-flex gap-2"> <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> <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>
</div> </div>
@@ -238,6 +222,7 @@
<img src="{{ url_for('static', filename='uploads/' + img) }}" <img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="{{ img }}"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"> data-bs-toggle="modal" data-bs-target="#imageModal">
</div> </div>
@@ -259,9 +244,25 @@
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img'); 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) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -294,15 +295,8 @@
}); });
const data = await response.json(); 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); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id; const jobResult = await waitForJob(data.job_id);
const jobResult = await waitForJob(currentJobId); if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
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;
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); } } 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'); } 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 batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -329,6 +323,7 @@
col.innerHTML = `<div class="position-relative"> col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded" <img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
title="${charName}"> title="${charName}">
@@ -347,36 +342,36 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); 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; 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(); const fd = new FormData();
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(form.getAttribute('action'), {
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' } method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
currentJobId = data.job_id; } catch (err) { console.error(`Submit error for ${char.name}:`, err); }
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'); }
} }
// 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%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;
@@ -387,7 +382,7 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...'; batchLabel.textContent = 'Stopping after current submissions...';
}); });
// JSON Editor // JSON Editor

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="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> <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="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').src)">
{% if character.image_path %} {% 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 %} {% else %}
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
{% endif %} {% endif %}
@@ -44,35 +44,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center"> <span>Selected Preview</span>
<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>
<form action="{{ url_for('replace_cover_from_preview', slug=character.slug) }}" method="post" class="m-0" id="replace-cover-form"> <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> </form>
</div> </div>
<div class="card-body p-0"> <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)"> <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> </div>
</div> </div>
{% endif %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <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> <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> <a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
</div> </div>
<a href="/" class="btn btn-outline-secondary">Back to Gallery</a> <a href="/" class="btn btn-outline-secondary">Back to Library</a>
</div> </div>
<!-- Outfit Switcher --> <!-- Outfit Switcher -->
@@ -227,9 +212,27 @@
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 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) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -254,55 +257,35 @@
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') { if (!submitter || submitter.value !== 'preview') return;
return;
}
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
// UI Reset
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = ''; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData,
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentJobId = data.job_id; currentJobId = data.job_id;
progressLabel.textContent = 'Queued…'; progressLabel.textContent = 'Queued…';
// Wait for the background worker to finish
const jobResult = await waitForJob(currentJobId); const jobResult = await waitForJob(currentJobId);
currentJobId = null; currentJobId = null;
// Image is already saved — just display it if (jobResult.result?.image_url) {
if (jobResult.result && jobResult.result.image_url) { selectPreview(jobResult.result.relative_path, 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;
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Generation failed: ' + err.message); alert('Generation failed: ' + err.message);
@@ -312,8 +295,7 @@
} }
}); });
}); });
// Image modal function
function showImage(src) { function showImage(src) {
document.getElementById('modalImage').src = src; document.getElementById('modalImage').src = src;
} }

View File

@@ -56,7 +56,7 @@
<div class="card mb-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').src)">
{% if detailer.image_path %} {% 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 %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
@@ -121,35 +121,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2"> <small>Selected Preview</small>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <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)"> <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> </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>
<div class="col-md-8"> <div class="col-md-8">
@@ -162,7 +147,7 @@
</div> </div>
<div class="d-flex gap-2"> <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> <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>
</div> </div>
@@ -257,7 +242,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <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 progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context'); const charContext = document.getElementById('character-context');
const actionSelect = document.getElementById('action_select'); const actionSelect = document.getElementById('action_select');
// Toggle character context info
charSelect.addEventListener('change', () => { charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') { charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
}); });
let currentJobId = null; function selectPreview(relativePath, imageUrl) {
let currentAction = null; 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) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -307,8 +303,7 @@
const data = await resp.json(); const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); } 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 === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…'; else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
else progressLabel.textContent = 'Queued…';
} catch (err) { console.error('Poll error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 1500); }, 1500);
}); });
@@ -318,9 +313,8 @@
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
progressContainer.classList.remove('d-none'); 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'); progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
@@ -330,26 +324,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); 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…';
const jobResult = await waitForJob(currentJobId); const jobResult = await waitForJob(data.job_id);
currentJobId = null; if (jobResult.result?.image_url) {
if (jobResult.result && jobResult.result.image_url) { selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
previewImg.src = jobResult.result.image_url; addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
} }
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); } } 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'); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
}); });
// Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -357,7 +346,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -368,8 +357,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -384,12 +374,13 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); 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; 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(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
@@ -398,26 +389,25 @@
fd.append('extra_negative', document.getElementById('extra_negative').value); fd.append('extra_negative', document.getElementById('extra_negative').value);
fd.append('action', 'preview'); fd.append('action', 'preview');
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
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 data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
currentJobId = data.job_id; } catch (err) { console.error(`Submit error for ${char.name}:`, err); }
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'); }
} }
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%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;
@@ -428,10 +418,9 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); 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) }}'); initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
}); });

View File

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

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="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> <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> <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="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</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> <a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div> <div class="vr mx-1 d-none d-lg-block"></div>
<!-- Queue indicator --> <!-- Queue indicator -->
@@ -48,19 +48,6 @@
</div> </div>
</nav> </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"> <div class="container">
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
{% if messages %} {% if messages %}
@@ -129,22 +116,44 @@
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el)); 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>
<script> <script>
// ---- Resource delete modal (category galleries) ---- // ---- Resource delete modal (category galleries) ----
@@ -340,14 +349,15 @@
if (!el) return; if (!el) return;
const dot = el.querySelector('.status-dot'); const dot = el.querySelector('.status-dot');
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error'); dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
if (id === 'status-comfyui' && window._updateComfyTooltip) {
window._updateComfyTooltip();
return;
}
const tooltipText = label + ': ' + (ok ? 'online' : 'offline'); const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
el.setAttribute('data-bs-title', tooltipText); el.setAttribute('data-bs-title', tooltipText);
el.setAttribute('title', tooltipText); el.setAttribute('title', tooltipText);
// Refresh tooltip instance if already initialised
const tip = bootstrap.Tooltip.getInstance(el); const tip = bootstrap.Tooltip.getInstance(el);
if (tip) { if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
tip.setContent({ '.tooltip-inner': tooltipText });
}
} }
async function pollService(svc) { async function pollService(svc) {

View File

@@ -43,9 +43,10 @@
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<div class="card mb-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 %} {% 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 %} {% else %}
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
{% endif %} {% endif %}
@@ -89,35 +90,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center"> <span>Selected Preview</span>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <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)"> <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) }}" 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> </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 mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -156,7 +142,7 @@
</div> </div>
<div class="d-flex gap-2"> <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> <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>
</div> </div>
@@ -237,9 +223,25 @@
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img'); 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) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -272,22 +274,15 @@
}); });
const data = await response.json(); 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); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id; const jobResult = await waitForJob(data.job_id);
const jobResult = await waitForJob(currentJobId); if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
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;
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); } } 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'); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
}); });
}); });
function showImage(src) { function showImage(src) {
document.getElementById('modalImage').src = src; if (src) document.getElementById('modalImage').src = src;
} }
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}'); initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="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> <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="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').src)">
{% if outfit.image_path %} {% 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 %} {% else %}
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
{% endif %} {% endif %}
@@ -86,35 +86,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center"> <span>Selected Preview</span>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <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)"> <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> </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 mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -152,7 +137,7 @@
</div> </div>
<div class="d-flex gap-2"> <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> <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>
</div> </div>
@@ -245,7 +230,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <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 progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
let currentJobId = null; const replaceBtn = document.getElementById('replace-cover-btn');
let currentAction = null; 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) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -298,53 +300,34 @@
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') { if (!submitter || submitter.value !== 'preview') return;
return;
}
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
// UI Reset
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = ''; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData,
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentJobId = data.job_id;
progressLabel.textContent = 'Queued…'; progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
const jobResult = await waitForJob(currentJobId); if (jobResult.result?.image_url) {
currentJobId = null; selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
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;
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Generation failed: ' + err.message); alert('Generation failed: ' + err.message);
@@ -366,7 +349,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -377,8 +360,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -393,43 +377,46 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); 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; 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(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
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 data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
currentJobId = data.job_id; } catch (err) { console.error(`Submit error for ${char.name}:`, err); }
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');
}
} }
// 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%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;
@@ -440,7 +427,7 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...'; batchLabel.textContent = 'Stopping';
}); });
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}'); initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="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> <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="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').src)">
{% if scene.image_path %} {% 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 %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
@@ -100,35 +100,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2"> <small>Selected Preview</small>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <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)"> <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> </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>
<div class="col-md-8"> <div class="col-md-8">
@@ -145,7 +130,7 @@
</div> </div>
<div class="d-flex gap-2"> <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> <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>
</div> </div>
@@ -255,7 +240,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <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 progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context'); const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => { charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') { charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
}); });
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) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -302,8 +300,7 @@
const data = await resp.json(); const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); } 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 === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…'; else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
else progressLabel.textContent = 'Queued…';
} catch (err) { console.error('Poll error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 1500); }, 1500);
}); });
@@ -324,26 +321,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); 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…';
const jobResult = await waitForJob(currentJobId); const jobResult = await waitForJob(data.job_id);
currentJobId = null; if (jobResult.result?.image_url) {
if (jobResult.result && jobResult.result.image_url) { selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
previewImg.src = jobResult.result.image_url; addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
} }
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); } } 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'); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
}); });
// Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -351,7 +343,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -362,8 +354,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -378,37 +371,37 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); 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; 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(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
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 data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
currentJobId = data.job_id; } catch (err) { console.error(`Submit error for ${char.name}:`, err); }
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'); }
} }
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%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;
@@ -419,10 +412,9 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); 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) }}'); initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
}); });

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="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> <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>
</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"> <div class="d-grid mt-4">
<button type="submit" class="btn btn-primary btn-lg">Save All Settings</button> <button type="submit" class="btn btn-primary btn-lg">Save All Settings</button>
</div> </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 // Local Model Loading
const connectLocalBtn = document.getElementById('connect-local-btn'); const connectLocalBtn = document.getElementById('connect-local-btn');
const localModelSelect = document.getElementById('local_model'); const localModelSelect = document.getElementById('local_model');

View File

@@ -56,7 +56,7 @@
<div class="card mb-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').src)">
{% if style.image_path %} {% 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 %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
@@ -100,35 +100,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2"> <small>Selected Preview</small>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <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)"> <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> </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>
<div class="col-md-8"> <div class="col-md-8">
@@ -145,7 +130,7 @@
</div> </div>
<div class="d-flex gap-2"> <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> <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>
</div> </div>
@@ -247,7 +232,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <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 progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context'); const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => { charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') { charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
}); });
let currentJobId = null; function selectPreview(relativePath, imageUrl) {
let currentAction = null; 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) { async function waitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -296,8 +292,7 @@
const data = await resp.json(); const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); } 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 === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…'; else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
else progressLabel.textContent = 'Queued…';
} catch (err) { console.error('Poll error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 1500); }, 1500);
}); });
@@ -307,9 +302,8 @@
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
progressContainer.classList.remove('d-none'); 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'); progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
@@ -319,26 +313,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); 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…';
const jobResult = await waitForJob(currentJobId); const jobResult = await waitForJob(data.job_id);
currentJobId = null; if (jobResult.result?.image_url) {
if (jobResult.result && jobResult.result.image_url) { selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
previewImg.src = jobResult.result.image_url; addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
} }
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); } } 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'); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
}); });
// Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -346,7 +335,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -357,8 +346,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -373,37 +363,37 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); 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; 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(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
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 data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
currentJobId = data.job_id; } catch (err) { console.error(`Submit error for ${char.name}:`, err); }
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'); }
} }
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%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;
@@ -414,10 +404,9 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); 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) }}'); initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
}); });

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="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> <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>