1 Commits

Author SHA1 Message Date
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 754 additions and 765 deletions

315
app.py
View File

@@ -385,6 +385,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 +402,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 +439,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 +1465,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 +1811,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,15 +2068,13 @@ 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):
@@ -2278,19 +2264,6 @@ 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 == '')).all()
@@ -2345,7 +2318,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 +2327,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']}
@@ -2428,7 +2403,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 +2466,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 +2525,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 +2644,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 +2721,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 +2964,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 +3067,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 +3083,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 +3222,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 +3240,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 +3260,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 +3325,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 +3531,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 +3695,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,15 +3728,13 @@ 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')
@@ -3819,7 +3795,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 +3855,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 +4037,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 +4231,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 +4264,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 +4339,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 +4533,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 +4691,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 +4716,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 +4749,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 +4776,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 +4836,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 +5072,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 +5083,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 +5092,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'])
@@ -5281,7 +5262,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 +5344,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 +5394,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 +5403,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 +5440,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(' ', '_'))
@@ -5513,7 +5495,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 +5558,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 +6370,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>