From a38915b3549beb59877d49aab1789baf2562b056 Mon Sep 17 00:00:00 2001 From: Aodhan Collins Date: Thu, 5 Mar 2026 22:48:28 +0000 Subject: [PATCH] Refactor UI, settings, and code quality across all categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.py | 315 +++++++++++++++--------------- models.py | 9 + templates/actions/detail.html | 168 +++++++--------- templates/actions/index.html | 2 +- templates/checkpoints/detail.html | 113 +++++------ templates/checkpoints/index.html | 2 +- templates/detail.html | 94 ++++----- templates/detailers/detail.html | 139 ++++++------- templates/detailers/index.html | 2 +- templates/gallery.html | 2 +- templates/index.html | 2 +- templates/layout.html | 76 +++---- templates/looks/detail.html | 67 +++---- templates/looks/index.html | 2 +- templates/outfits/detail.html | 167 ++++++++-------- templates/outfits/index.html | 2 +- templates/scenes/detail.html | 134 ++++++------- templates/scenes/index.html | 2 +- templates/settings.html | 80 ++++++++ templates/styles/detail.html | 139 ++++++------- templates/styles/index.html | 2 +- 21 files changed, 754 insertions(+), 765 deletions(-) diff --git a/app.py b/app.py index 30d2553..77f75db 100644 --- a/app.py +++ b/app.py @@ -385,6 +385,7 @@ def inject_default_checkpoint(): @app.route('/set_default_checkpoint', methods=['POST']) def set_default_checkpoint(): session['default_checkpoint'] = request.form.get('checkpoint_path', '') + session.modified = True return {'status': 'ok'} @@ -401,6 +402,27 @@ def api_status_comfyui(): return {'status': 'error'} +@app.route('/api/comfyui/loaded_checkpoint') +def api_comfyui_loaded_checkpoint(): + """Return the checkpoint name from the most recently completed ComfyUI job.""" + url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') + try: + resp = requests.get(f'{url}/history', timeout=3) + if not resp.ok: + return {'checkpoint': None} + history = resp.json() + if not history: + return {'checkpoint': None} + # Sort by timestamp descending, take the most recent job + latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', '')) + # Node "4" is the checkpoint loader in the workflow + nodes = latest.get('prompt', [None, None, {}])[2] + ckpt_name = nodes.get('4', {}).get('inputs', {}).get('ckpt_name') + return {'checkpoint': ckpt_name} + except Exception: + return {'checkpoint': None} + + @app.route('/api/status/mcp') def api_status_mcp(): """Return whether the danbooru-mcp Docker container is running.""" @@ -417,80 +439,39 @@ def api_status_mcp(): ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} -def get_available_loras(): - loras = [] - if os.path.exists(app.config['LORA_DIR']): - for f in os.listdir(app.config['LORA_DIR']): - if f.endswith('.safetensors'): - # Using the format seen in character JSONs - loras.append(f"Illustrious/Looks/{f}") - return sorted(loras) +_LORA_DEFAULTS = { + 'characters': '/ImageModels/lora/Illustrious/Looks', + 'outfits': '/ImageModels/lora/Illustrious/Clothing', + 'actions': '/ImageModels/lora/Illustrious/Poses', + 'styles': '/ImageModels/lora/Illustrious/Styles', + 'scenes': '/ImageModels/lora/Illustrious/Backgrounds', + 'detailers': '/ImageModels/lora/Illustrious/Detailers', +} -def get_available_clothing_loras(): - """Get LoRAs from the Clothing directory for outfit LoRAs.""" - clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/' - loras = [] - if os.path.exists(clothing_lora_dir): - for f in os.listdir(clothing_lora_dir): - if f.endswith('.safetensors'): - loras.append(f"Illustrious/Clothing/{f}") - return sorted(loras) - -def get_available_action_loras(): - """Get LoRAs from the Poses directory for action LoRAs.""" - poses_lora_dir = '/ImageModels/lora/Illustrious/Poses/' - loras = [] - if os.path.exists(poses_lora_dir): - for f in os.listdir(poses_lora_dir): - if f.endswith('.safetensors'): - loras.append(f"Illustrious/Poses/{f}") - return sorted(loras) - -def get_available_style_loras(): - """Get LoRAs from the Styles directory for style LoRAs.""" - styles_lora_dir = '/ImageModels/lora/Illustrious/Styles/' - loras = [] - if os.path.exists(styles_lora_dir): - for f in os.listdir(styles_lora_dir): - if f.endswith('.safetensors'): - loras.append(f"Illustrious/Styles/{f}") - return sorted(loras) - -def get_available_detailer_loras(): - """Get LoRAs from the Detailers directory for detailer LoRAs.""" - detailers_lora_dir = '/ImageModels/lora/Illustrious/Detailers/' - loras = [] - if os.path.exists(detailers_lora_dir): - for f in os.listdir(detailers_lora_dir): - if f.endswith('.safetensors'): - loras.append(f"Illustrious/Detailers/{f}") - return sorted(loras) - -def get_available_scene_loras(): - """Get LoRAs from the Backgrounds directory for scene LoRAs.""" - backgrounds_lora_dir = '/ImageModels/lora/Illustrious/Backgrounds/' - loras = [] - if os.path.exists(backgrounds_lora_dir): - for f in os.listdir(backgrounds_lora_dir): - if f.endswith('.safetensors'): - loras.append(f"Illustrious/Backgrounds/{f}") - return sorted(loras) +def get_available_loras(category): + """Return sorted list of LoRA paths for the given category. + category: one of 'characters','outfits','actions','styles','scenes','detailers' + """ + settings = Settings.query.first() + lora_dir = (getattr(settings, f'lora_dir_{category}', None) if settings else None) or _LORA_DEFAULTS.get(category, '') + if not lora_dir or not os.path.isdir(lora_dir): + return [] + subfolder = os.path.basename(lora_dir.rstrip('/')) + return sorted(f"Illustrious/{subfolder}/{f}" for f in os.listdir(lora_dir) if f.endswith('.safetensors')) def get_available_checkpoints(): + settings = Settings.query.first() + checkpoint_dirs_str = (settings.checkpoint_dirs if settings else None) or \ + '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob' checkpoints = [] - - # Scan Illustrious - if os.path.exists(app.config['ILLUSTRIOUS_MODELS_DIR']): - for f in os.listdir(app.config['ILLUSTRIOUS_MODELS_DIR']): + for ckpt_dir in checkpoint_dirs_str.split(','): + ckpt_dir = ckpt_dir.strip() + if not ckpt_dir or not os.path.isdir(ckpt_dir): + continue + prefix = os.path.basename(ckpt_dir.rstrip('/')) + for f in os.listdir(ckpt_dir): if f.endswith('.safetensors') or f.endswith('.ckpt'): - checkpoints.append(f"Illustrious/{f}") - - # Scan Noob - if os.path.exists(app.config['NOOB_MODELS_DIR']): - for f in os.listdir(app.config['NOOB_MODELS_DIR']): - if f.endswith('.safetensors') or f.endswith('.ckpt'): - checkpoints.append(f"Noob/{f}") - + checkpoints.append(f"{prefix}/{f}") return sorted(checkpoints) def allowed_file(filename): @@ -1484,10 +1465,17 @@ def settings(): settings.openrouter_model = request.form.get('model') settings.local_base_url = request.form.get('local_base_url') settings.local_model = request.form.get('local_model') + settings.lora_dir_characters = request.form.get('lora_dir_characters') or settings.lora_dir_characters + settings.lora_dir_outfits = request.form.get('lora_dir_outfits') or settings.lora_dir_outfits + settings.lora_dir_actions = request.form.get('lora_dir_actions') or settings.lora_dir_actions + settings.lora_dir_styles = request.form.get('lora_dir_styles') or settings.lora_dir_styles + settings.lora_dir_scenes = request.form.get('lora_dir_scenes') or settings.lora_dir_scenes + settings.lora_dir_detailers = request.form.get('lora_dir_detailers') or settings.lora_dir_detailers + settings.checkpoint_dirs = request.form.get('checkpoint_dirs') or settings.checkpoint_dirs db.session.commit() flash('Settings updated successfully!') return redirect(url_for('settings')) - + return render_template('settings.html', settings=settings) @app.route('/') @@ -1823,7 +1811,7 @@ def create_character(): @app.route('/character//edit', methods=['GET', 'POST']) def edit_character(slug): character = Character.query.filter_by(slug=slug).first_or_404() - loras = get_available_loras() + loras = get_available_loras('characters') char_looks = Look.query.filter_by(character_id=character.character_id).order_by(Look.name).all() if request.method == 'POST': @@ -2080,15 +2068,13 @@ def upload_image(slug): @app.route('/character//replace_cover_from_preview', methods=['POST']) def replace_cover_from_preview(slug): character = Character.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_{slug}') - - if preview_path: + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): character.image_path = preview_path db.session.commit() - flash('Cover image updated from preview!') + flash('Cover image updated!') else: - flash('No preview image available', 'error') - + flash('No valid preview image selected.', 'error') return redirect(url_for('detail', slug=slug)) def _log_workflow_prompts(label, workflow): @@ -2278,19 +2264,6 @@ def _get_default_checkpoint(): return None, None return ckpt.checkpoint_path, ckpt.data or {} -def _queue_generation(character, action='preview', selected_fields=None, client_id=None): - # 1. Load workflow - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - # 2. Build prompts with active outfit - prompts = build_prompt(character.data, selected_fields, character.default_fields, character.active_outfit) - - # 3. Prepare workflow - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - - return queue_prompt(workflow, client_id=client_id) @app.route('/get_missing_characters') def get_missing_characters(): missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).all() @@ -2345,7 +2318,8 @@ def generate_image(slug): # Save preferences session[f'prefs_{slug}'] = selected_fields - + session.modified = True + # Build workflow with open('comfy_workflow.json', 'r') as f: workflow = json.load(f) @@ -2353,6 +2327,7 @@ def generate_image(slug): ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) + label = f"{character.name} – {action}" job = _enqueue_job(label, workflow, _make_finalize('characters', slug, Character, action)) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'status': 'queued', 'job_id': job['id']} @@ -2428,7 +2403,9 @@ def rescan_outfits(): @app.route('/outfits/bulk_create', methods=['POST']) def bulk_create_outfits_from_loras(): - clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/' + _s = Settings.query.first() + clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/') + _lora_subfolder = os.path.basename(clothing_lora_dir) if not os.path.exists(clothing_lora_dir): flash('Clothing LoRA directory not found.', 'error') return redirect(url_for('outfits_index')) @@ -2489,7 +2466,7 @@ def bulk_create_outfits_from_loras(): if 'lora' not in outfit_data: outfit_data['lora'] = {} - outfit_data['lora']['lora_name'] = f"Illustrious/Clothing/{filename}" + outfit_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" if not outfit_data['lora'].get('lora_triggers'): outfit_data['lora']['lora_triggers'] = name_base if outfit_data['lora'].get('lora_weight') is None: @@ -2548,7 +2525,7 @@ def outfit_detail(slug): @app.route('/outfit//edit', methods=['GET', 'POST']) def edit_outfit(slug): outfit = Outfit.query.filter_by(slug=slug).first_or_404() - loras = get_available_clothing_loras() # Use clothing LoRAs for outfits + loras = get_available_loras('outfits') # Use clothing LoRAs for outfits if request.method == 'POST': try: @@ -2667,7 +2644,8 @@ def generate_outfit_image(slug): # Save preferences session[f'prefs_outfit_{slug}'] = selected_fields session[f'char_outfit_{slug}'] = character_slug - + session.modified = True + # Build combined data for prompt building if character: # Combine character identity/defaults with outfit wardrobe @@ -2743,15 +2721,13 @@ def generate_outfit_image(slug): @app.route('/outfit//replace_cover_from_preview', methods=['POST']) def replace_outfit_cover_from_preview(slug): outfit = Outfit.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_outfit_{slug}') - - if preview_path: + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): outfit.image_path = preview_path db.session.commit() - flash('Cover image updated from preview!') + flash('Cover image updated!') else: - flash('No preview image available', 'error') - + flash('No valid preview image selected.', 'error') return redirect(url_for('outfit_detail', slug=slug)) @app.route('/outfit/create', methods=['GET', 'POST']) @@ -2988,7 +2964,7 @@ def action_detail(slug): @app.route('/action//edit', methods=['GET', 'POST']) def edit_action(slug): action = Action.query.filter_by(slug=slug).first_or_404() - loras = get_available_action_loras() + loras = get_available_loras('actions') if request.method == 'POST': try: @@ -3091,7 +3067,7 @@ def generate_action_image(slug): try: # Get action type - action_type = request.form.get('action', 'preview') + action = request.form.get('action', 'preview') # Get selected fields selected_fields = request.form.getlist('include_field') @@ -3107,7 +3083,8 @@ def generate_action_image(slug): # Save preferences session[f'char_action_{slug}'] = character_slug session[f'prefs_action_{slug}'] = selected_fields - + session.modified = True + # Build combined data for prompt building if character: # Combine character identity/wardrobe with action details @@ -3245,8 +3222,8 @@ def generate_action_image(slug): workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) char_label = character.name if character else 'no character' - label = f"Action: {action_obj.name} ({char_label}) – {action_type}" - job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action_type)) + label = f"Action: {action_obj.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action)) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'status': 'queued', 'job_id': job['id']} @@ -3263,15 +3240,13 @@ def generate_action_image(slug): @app.route('/action//replace_cover_from_preview', methods=['POST']) def replace_action_cover_from_preview(slug): action = Action.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_action_{slug}') - - if preview_path: + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): action.image_path = preview_path db.session.commit() - flash('Cover image updated from preview!') + flash('Cover image updated!') else: - flash('No preview image available', 'error') - + flash('No valid preview image selected.', 'error') return redirect(url_for('action_detail', slug=slug)) @app.route('/action//save_defaults', methods=['POST']) @@ -3285,7 +3260,9 @@ def save_action_defaults(slug): @app.route('/actions/bulk_create', methods=['POST']) def bulk_create_actions_from_loras(): - actions_lora_dir = '/ImageModels/lora/Illustrious/Poses/' + _s = Settings.query.first() + actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/') + _lora_subfolder = os.path.basename(actions_lora_dir) if not os.path.exists(actions_lora_dir): flash('Actions LoRA directory not found.', 'error') return redirect(url_for('actions_index')) @@ -3348,7 +3325,7 @@ def bulk_create_actions_from_loras(): # Update lora dict safely if 'lora' not in action_data: action_data['lora'] = {} - action_data['lora']['lora_name'] = f"Illustrious/Poses/{filename}" + action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" # Fallbacks if LLM failed to extract metadata if not action_data['lora'].get('lora_triggers'): @@ -3554,7 +3531,7 @@ def style_detail(slug): @app.route('/style//edit', methods=['GET', 'POST']) def edit_style(slug): style = Style.query.filter_by(slug=slug).first_or_404() - loras = get_available_style_loras() + loras = get_available_loras('styles') if request.method == 'POST': try: @@ -3718,7 +3695,8 @@ def generate_style_image(slug): # Save preferences session[f'char_style_{slug}'] = character_slug session[f'prefs_style_{slug}'] = selected_fields - + session.modified = True + # Build workflow using helper (returns workflow dict, not prompt_response) workflow = _build_style_workflow(style_obj, character, selected_fields) @@ -3750,15 +3728,13 @@ def save_style_defaults(slug): @app.route('/style//replace_cover_from_preview', methods=['POST']) def replace_style_cover_from_preview(slug): style = Style.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_style_{slug}') - - if preview_path: + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): style.image_path = preview_path db.session.commit() - flash('Cover image updated from preview!') + flash('Cover image updated!') else: - flash('No preview image available', 'error') - + flash('No valid preview image selected.', 'error') return redirect(url_for('style_detail', slug=slug)) @app.route('/get_missing_styles') @@ -3819,7 +3795,9 @@ def generate_missing_styles(): @app.route('/styles/bulk_create', methods=['POST']) def bulk_create_styles_from_loras(): - styles_lora_dir = '/ImageModels/lora/Illustrious/Styles/' + _s = Settings.query.first() + styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/') + _lora_subfolder = os.path.basename(styles_lora_dir) if not os.path.exists(styles_lora_dir): flash('Styles LoRA directory not found.', 'error') return redirect(url_for('styles_index')) @@ -3877,7 +3855,7 @@ def bulk_create_styles_from_loras(): style_data['style_name'] = style_name if 'lora' not in style_data: style_data['lora'] = {} - style_data['lora']['lora_name'] = f"Illustrious/Styles/{filename}" + style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" if not style_data['lora'].get('lora_triggers'): style_data['lora']['lora_triggers'] = name_base @@ -4059,7 +4037,7 @@ def scene_detail(slug): @app.route('/scene//edit', methods=['GET', 'POST']) def edit_scene(slug): scene = Scene.query.filter_by(slug=slug).first_or_404() - loras = get_available_scene_loras() + loras = get_available_loras('scenes') if request.method == 'POST': try: @@ -4253,7 +4231,8 @@ def generate_scene_image(slug): # Save preferences session[f'char_scene_{slug}'] = character_slug session[f'prefs_scene_{slug}'] = selected_fields - + session.modified = True + # Build workflow using helper workflow = _queue_scene_generation(scene_obj, character, selected_fields) @@ -4285,20 +4264,20 @@ def save_scene_defaults(slug): @app.route('/scene//replace_cover_from_preview', methods=['POST']) def replace_scene_cover_from_preview(slug): scene = Scene.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_scene_{slug}') - - if preview_path: + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): scene.image_path = preview_path db.session.commit() - flash('Cover image updated from preview!') + flash('Cover image updated!') else: - flash('No preview image available', 'error') - + flash('No valid preview image selected.', 'error') return redirect(url_for('scene_detail', slug=slug)) @app.route('/scenes/bulk_create', methods=['POST']) def bulk_create_scenes_from_loras(): - backgrounds_lora_dir = '/ImageModels/lora/Illustrious/Backgrounds/' + _s = Settings.query.first() + backgrounds_lora_dir = ((_s.lora_dir_scenes if _s else None) or '/ImageModels/lora/Illustrious/Backgrounds').rstrip('/') + _lora_subfolder = os.path.basename(backgrounds_lora_dir) if not os.path.exists(backgrounds_lora_dir): flash('Backgrounds LoRA directory not found.', 'error') return redirect(url_for('scenes_index')) @@ -4360,7 +4339,7 @@ def bulk_create_scenes_from_loras(): scene_data['scene_name'] = scene_name if 'lora' not in scene_data: scene_data['lora'] = {} - scene_data['lora']['lora_name'] = f"Illustrious/Backgrounds/{filename}" + scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" if not scene_data['lora'].get('lora_triggers'): scene_data['lora']['lora_triggers'] = name_base @@ -4554,7 +4533,7 @@ def detailer_detail(slug): @app.route('/detailer//edit', methods=['GET', 'POST']) def edit_detailer(slug): detailer = Detailer.query.filter_by(slug=slug).first_or_404() - loras = get_available_detailer_loras() + loras = get_available_loras('detailers') if request.method == 'POST': try: @@ -4712,8 +4691,8 @@ def generate_detailer_image(slug): try: # Get action type - action_type = request.form.get('action', 'preview') - + action = request.form.get('action', 'preview') + # Get selected fields selected_fields = request.form.getlist('include_field') @@ -4737,13 +4716,14 @@ def generate_detailer_image(slug): session[f'extra_pos_detailer_{slug}'] = extra_positive session[f'extra_neg_detailer_{slug}'] = extra_negative session[f'prefs_detailer_{slug}'] = selected_fields + session.modified = True # Build workflow using helper workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative) char_label = character.name if character else 'no character' - label = f"Detailer: {detailer_obj.name} ({char_label}) – {action_type}" - job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action_type)) + label = f"Detailer: {detailer_obj.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action)) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'status': 'queued', 'job_id': job['id']} @@ -4769,15 +4749,13 @@ def save_detailer_defaults(slug): @app.route('/detailer//replace_cover_from_preview', methods=['POST']) def replace_detailer_cover_from_preview(slug): detailer = Detailer.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_detailer_{slug}') - - if preview_path: + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): detailer.image_path = preview_path db.session.commit() - flash('Cover image updated from preview!') + flash('Cover image updated!') else: - flash('No preview image available', 'error') - + flash('No valid preview image selected.', 'error') return redirect(url_for('detailer_detail', slug=slug)) @app.route('/detailer//save_json', methods=['POST']) @@ -4798,7 +4776,9 @@ def save_detailer_json(slug): @app.route('/detailers/bulk_create', methods=['POST']) def bulk_create_detailers_from_loras(): - detailers_lora_dir = '/ImageModels/lora/Illustrious/Detailers/' + _s = Settings.query.first() + detailers_lora_dir = ((_s.lora_dir_detailers if _s else None) or '/ImageModels/lora/Illustrious/Detailers').rstrip('/') + _lora_subfolder = os.path.basename(detailers_lora_dir) if not os.path.exists(detailers_lora_dir): flash('Detailers LoRA directory not found.', 'error') return redirect(url_for('detailers_index')) @@ -4856,7 +4836,7 @@ def bulk_create_detailers_from_loras(): detailer_data['detailer_name'] = detailer_name if 'lora' not in detailer_data: detailer_data['lora'] = {} - detailer_data['lora']['lora_name'] = f"Illustrious/Detailers/{filename}" + detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" if not detailer_data['lora'].get('lora_triggers'): detailer_data['lora']['lora_triggers'] = name_base @@ -5092,6 +5072,7 @@ def generate_checkpoint_image(slug): character_slug = character.slug session[f'char_checkpoint_{slug}'] = character_slug + session.modified = True workflow = _build_checkpoint_workflow(ckpt, character) char_label = character.name if character else 'random' @@ -5102,7 +5083,7 @@ def generate_checkpoint_image(slug): return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('checkpoint_detail', slug=slug)) except Exception as e: - print(f"Checkpoint generation error: {e}") + print(f"Generation error: {e}") if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") @@ -5111,13 +5092,13 @@ def generate_checkpoint_image(slug): @app.route('/checkpoint//replace_cover_from_preview', methods=['POST']) def replace_checkpoint_cover_from_preview(slug): ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_checkpoint_{slug}') - if preview_path: + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): ckpt.image_path = preview_path db.session.commit() - flash('Cover image updated from preview!') + flash('Cover image updated!') else: - flash('No preview image available', 'error') + flash('No valid preview image selected.', 'error') return redirect(url_for('checkpoint_detail', slug=slug)) @app.route('/checkpoint//save_json', methods=['POST']) @@ -5281,7 +5262,7 @@ def look_detail(slug): def edit_look(slug): look = Look.query.filter_by(slug=slug).first_or_404() characters = Character.query.order_by(Character.name).all() - loras = get_available_loras() + loras = get_available_loras('characters') if request.method == 'POST': look.name = request.form.get('look_name', look.name) @@ -5363,6 +5344,7 @@ def generate_look_image(slug): session[f'prefs_look_{slug}'] = selected_fields session[f'char_look_{slug}'] = character_slug + session.modified = True lora_triggers = look.data.get('lora', {}).get('lora_triggers', '') look_positive = look.data.get('positive', '') @@ -5412,7 +5394,7 @@ def generate_look_image(slug): return redirect(url_for('look_detail', slug=slug)) except Exception as e: - print(f"Look generation error: {e}") + print(f"Generation error: {e}") if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") @@ -5421,13 +5403,13 @@ def generate_look_image(slug): @app.route('/look//replace_cover_from_preview', methods=['POST']) def replace_look_cover_from_preview(slug): look = Look.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_look_{slug}') - if preview_path: + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): look.image_path = preview_path db.session.commit() - flash('Cover image updated from preview!') + flash('Cover image updated!') else: - flash('No preview image available', 'error') + flash('No valid preview image selected.', 'error') return redirect(url_for('look_detail', slug=slug)) @app.route('/look//save_defaults', methods=['POST']) @@ -5458,7 +5440,7 @@ def save_look_json(slug): @app.route('/look/create', methods=['GET', 'POST']) def create_look(): characters = Character.query.order_by(Character.name).all() - loras = get_available_loras() + loras = get_available_loras('characters') if request.method == 'POST': name = request.form.get('name', '').strip() look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_')) @@ -5513,7 +5495,9 @@ def clear_all_look_covers(): @app.route('/looks/bulk_create', methods=['POST']) def bulk_create_looks_from_loras(): - lora_dir = app.config['LORA_DIR'] + _s = Settings.query.first() + lora_dir = ((_s.lora_dir_characters if _s else None) or app.config['LORA_DIR']).rstrip('/') + _lora_subfolder = os.path.basename(lora_dir) if not os.path.exists(lora_dir): flash('Looks LoRA directory not found.', 'error') return redirect(url_for('looks_index')) @@ -5574,7 +5558,7 @@ def bulk_create_looks_from_loras(): if 'lora' not in look_data: look_data['lora'] = {} - look_data['lora']['lora_name'] = f"Illustrious/Looks/{filename}" + look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" if not look_data['lora'].get('lora_triggers'): look_data['lora']['lora_triggers'] = name_base if look_data['lora'].get('lora_weight') is None: @@ -6386,7 +6370,14 @@ if __name__ == '__main__': columns_to_add = [ ('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"), ('local_base_url', "VARCHAR(255)"), - ('local_model', "VARCHAR(100)") + ('local_model', "VARCHAR(100)"), + ('lora_dir_characters', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Looks'"), + ('lora_dir_outfits', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Clothing'"), + ('lora_dir_actions', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Poses'"), + ('lora_dir_styles', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Styles'"), + ('lora_dir_scenes', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Backgrounds'"), + ('lora_dir_detailers', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Detailers'"), + ('checkpoint_dirs', "VARCHAR(1000) DEFAULT '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'"), ] for col_name, col_type in columns_to_add: try: diff --git a/models.py b/models.py index 46b858b..0eed0eb 100644 --- a/models.py +++ b/models.py @@ -132,6 +132,15 @@ class Settings(db.Model): openrouter_model = db.Column(db.String(100), default='google/gemini-2.0-flash-001') local_base_url = db.Column(db.String(255), nullable=True) local_model = db.Column(db.String(100), nullable=True) + # LoRA directories (absolute paths on disk) + lora_dir_characters = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Looks') + lora_dir_outfits = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Clothing') + lora_dir_actions = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Poses') + lora_dir_styles = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Styles') + lora_dir_scenes = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Backgrounds') + lora_dir_detailers = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Detailers') + # Checkpoint scan directories (comma-separated list of absolute paths) + checkpoint_dirs = db.Column(db.String(1000), default='/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob') def __repr__(self): return '' diff --git a/templates/actions/detail.html b/templates/actions/detail.html index 003382f..710b585 100644 --- a/templates/actions/detail.html +++ b/templates/actions/detail.html @@ -56,7 +56,7 @@
{% if action.image_path %} - {{ action.name }} + {{ action.name }} {% else %} No Image Attached {% endif %} @@ -97,35 +97,20 @@
- {% if preview_image %} -
-
- Latest Preview +
+
+ Selected Preview
- + +
- Preview + Preview
- {% else %} -
-
- Latest Preview -
- -
-
-
-
- Preview -
-
-
- {% endif %}
@@ -163,7 +148,7 @@
@@ -288,7 +273,8 @@ class="img-fluid rounded" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" onclick="showImage(this.src)" - data-bs-toggle="modal" data-bs-target="#imageModal"> + data-bs-toggle="modal" data-bs-target="#imageModal" + data-preview-path="{{ img }}">
{% else %} @@ -314,20 +300,30 @@ const progressLabel = document.getElementById('progress-label'); const previewCard = document.getElementById('preview-card'); const previewImg = document.getElementById('preview-img'); + const previewPath = document.getElementById('preview-path'); + const replaceBtn = document.getElementById('replace-cover-btn'); + const previewHeader = document.getElementById('preview-card-header'); const charSelect = document.getElementById('character_select'); const charContext = document.getElementById('character-context'); - - // Toggle character context info + charSelect.addEventListener('change', () => { - if (charSelect.value && charSelect.value !== '__random__') { - charContext.classList.remove('d-none'); - } else { - charContext.classList.add('d-none'); - } + charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__'); }); - let currentJobId = null; - let currentAction = null; + function selectPreview(relativePath, imageUrl) { + if (!relativePath) return; + previewImg.src = imageUrl; + previewPath.value = relativePath; + replaceBtn.disabled = false; + previewCard.classList.remove('d-none'); + previewHeader.classList.replace('bg-secondary', 'bg-success'); + previewCard.classList.replace('border-secondary', 'border-success'); + } + + document.addEventListener('click', e => { + const img = e.target.closest('img[data-preview-path]'); + if (img) selectPreview(img.dataset.previewPath, img.src); + }); async function waitForJob(jobId) { return new Promise((resolve, reject) => { @@ -335,17 +331,9 @@ try { const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'done') { - clearInterval(poll); - resolve(data); - } else if (data.status === 'failed' || data.status === 'removed') { - clearInterval(poll); - reject(new Error(data.error || 'Job failed')); - } else if (data.status === 'processing') { - progressLabel.textContent = 'Generating…'; - } else { - progressLabel.textContent = 'Queued…'; - } + if (data.status === 'done') { clearInterval(poll); resolve(data); } + else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); } + else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…'; } catch (err) { console.error('Poll error:', err); } }, 1500); }); @@ -355,12 +343,10 @@ const submitter = e.submitter; if (!submitter || submitter.value !== 'preview') return; e.preventDefault(); - currentAction = submitter.value; const formData = new FormData(form); - formData.append('action', currentAction); + formData.append('action', 'preview'); progressContainer.classList.remove('d-none'); - progressBar.style.width = '100%'; - progressBar.textContent = ''; + progressBar.style.width = '100%'; progressBar.textContent = ''; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); progressLabel.textContent = 'Queuing…'; try { @@ -368,31 +354,21 @@ method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await response.json(); - if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; } - currentJobId = data.job_id; - const jobResult = await waitForJob(currentJobId); - currentJobId = null; - if (jobResult.result && jobResult.result.image_url) { - previewImg.src = jobResult.result.image_url; - if (previewCard) previewCard.classList.remove('d-none'); - const replaceBtn = document.getElementById('replace-cover-btn'); - if (replaceBtn) replaceBtn.disabled = false; + if (data.error) { alert('Error: ' + data.error); return; } + progressLabel.textContent = 'Queued…'; + const jobResult = await waitForJob(data.job_id); + if (jobResult.result?.image_url) { + selectPreview(jobResult.result.relative_path, jobResult.result.image_url); + addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, ''); } - } catch (err) { - console.error(err); - alert('Generation failed: ' + err.message); - } finally { - progressContainer.classList.add('d-none'); - progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } + } catch (err) { console.error(err); alert('Generation failed: ' + err.message); } + finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } }); - // Batch: Generate All Characters const allCharacters = [ {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% endfor %} ]; - let stopBatch = false; const generateAllBtn = document.getElementById('generate-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn'); @@ -400,7 +376,7 @@ const batchLabel = document.getElementById('batch-label'); const batchBar = document.getElementById('batch-bar'); - function addToPreviewGallery(imageUrl, charName) { + function addToPreviewGallery(imageUrl, relativePath, charName) { const gallery = document.getElementById('preview-gallery'); const placeholder = document.getElementById('gallery-empty'); if (placeholder) placeholder.remove(); @@ -411,8 +387,9 @@ style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" onclick="showImage(this.src)" data-bs-toggle="modal" data-bs-target="#imageModal" + data-preview-path="${relativePath}" title="${charName}"> -
${charName}
+ ${charName ? `
${charName}
` : ''}
`; gallery.insertBefore(col, gallery.firstChild); const badge = document.querySelector('#previews-tab .badge'); @@ -427,43 +404,37 @@ stopAllBtn.classList.remove('d-none'); batchProgress.classList.remove('d-none'); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); - for (let i = 0; i < allCharacters.length; i++) { + + const genForm = document.getElementById('generate-form'); + const formAction = genForm.getAttribute('action'); + batchLabel.textContent = 'Queuing all characters…'; + const pending = []; + for (const char of allCharacters) { if (stopBatch) break; - const char = allCharacters[i]; - batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`; - batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`; - const genForm = document.getElementById('generate-form'); const fd = new FormData(); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); fd.append('character_slug', char.slug); fd.append('action', 'preview'); try { - progressContainer.classList.remove('d-none'); - progressBar.style.width = '100%'; - progressBar.textContent = ''; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - progressLabel.textContent = `${char.name}: Queuing…`; - const resp = await fetch(genForm.getAttribute('action'), { - method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } - }); + const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); - if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - currentJobId = data.job_id; - const jobResult = await waitForJob(currentJobId); - currentJobId = null; - if (jobResult.result && jobResult.result.image_url) { - addToPreviewGallery(jobResult.result.image_url, char.name); - previewImg.src = jobResult.result.image_url; - if (previewCard) previewCard.classList.remove('d-none'); - } - } catch (err) { - console.error(`Failed for ${char.name}:`, err); - currentJobId = null; - } finally { - progressContainer.classList.add('d-none'); - progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } + if (!data.error) pending.push({ char, jobId: data.job_id }); + } catch (err) { console.error(`Submit error for ${char.name}:`, err); } } + + batchBar.style.width = '0%'; + let done = 0; + const total = pending.length; + batchLabel.textContent = `0 / ${total} complete`; + await Promise.all(pending.map(({ char, jobId }) => + waitForJob(jobId).then(result => { + done++; + batchBar.style.width = `${Math.round((done / total) * 100)}%`; + batchLabel.textContent = `${done} / ${total} complete`; + if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name); + }).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); }) + )); + batchBar.style.width = '100%'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; generateAllBtn.disabled = false; @@ -474,10 +445,9 @@ stopAllBtn.addEventListener('click', () => { stopBatch = true; stopAllBtn.classList.add('d-none'); - batchLabel.textContent = 'Stopping after current generation...'; + batchLabel.textContent = 'Stopping…'; }); - // JSON Editor initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}'); }); diff --git a/templates/actions/index.html b/templates/actions/index.html index 4064675..152a4b3 100644 --- a/templates/actions/index.html +++ b/templates/actions/index.html @@ -2,7 +2,7 @@ {% block content %}
-

Action Gallery

+

Action Library

diff --git a/templates/checkpoints/detail.html b/templates/checkpoints/detail.html index 3d3def3..98b7994 100644 --- a/templates/checkpoints/detail.html +++ b/templates/checkpoints/detail.html @@ -47,7 +47,8 @@ data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')"> {% if ckpt.image_path %} - {{ ckpt.name }} + {{ ckpt.name }} {% else %}
No Image Attached @@ -89,39 +90,22 @@
- {% if preview_image %} -
-
- Latest Preview -
- -
-
-
-
- Preview -
-
-
- {% else %} -
-
- Latest Preview +
+
+ Selected Preview
- + +
- Preview + onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')"> + Preview
- {% endif %}
@@ -131,7 +115,7 @@
@@ -238,6 +222,7 @@
@@ -259,9 +244,25 @@ const progressContainer = document.getElementById('progress-container'); const progressLabel = document.getElementById('progress-label'); const previewCard = document.getElementById('preview-card'); + const previewCardHeader = document.getElementById('preview-card-header'); const previewImg = document.getElementById('preview-img'); + const previewPath = document.getElementById('preview-path'); + const replaceBtn = document.getElementById('replace-cover-btn'); - let currentJobId = null; + function selectPreview(relativePath, imageUrl) { + if (!relativePath) return; + previewImg.src = imageUrl; + previewPath.value = relativePath; + replaceBtn.disabled = false; + previewCard.classList.remove('d-none'); + previewCardHeader.classList.replace('bg-secondary', 'bg-success'); + previewCard.classList.replace('border-secondary', 'border-success'); + } + + document.addEventListener('click', e => { + const img = e.target.closest('img[data-preview-path]'); + if (img) selectPreview(img.dataset.previewPath, img.src); + }); async function waitForJob(jobId) { return new Promise((resolve, reject) => { @@ -294,15 +295,8 @@ }); const data = await response.json(); if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; } - currentJobId = data.job_id; - const jobResult = await waitForJob(currentJobId); - currentJobId = null; - if (jobResult.result && jobResult.result.image_url) { - previewImg.src = jobResult.result.image_url; - if (previewCard) previewCard.classList.remove('d-none'); - const replaceBtn = document.getElementById('replace-cover-btn'); - if (replaceBtn) replaceBtn.disabled = false; - } + const jobResult = await waitForJob(data.job_id); + if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url); } catch (err) { console.error(err); alert('Generation failed: ' + err.message); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } }); @@ -320,7 +314,7 @@ const batchLabel = document.getElementById('batch-label'); const batchBar = document.getElementById('batch-bar'); - function addToPreviewGallery(imageUrl, charName) { + function addToPreviewGallery(imageUrl, relativePath, charName) { const gallery = document.getElementById('preview-gallery'); const placeholder = document.getElementById('gallery-empty'); if (placeholder) placeholder.remove(); @@ -329,6 +323,7 @@ col.innerHTML = `
@@ -347,36 +342,36 @@ stopAllBtn.classList.remove('d-none'); batchProgress.classList.remove('d-none'); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); - for (let i = 0; i < allCharacters.length; i++) { + + // Phase 1: submit all jobs immediately + const pending = []; + for (const char of allCharacters) { if (stopBatch) break; - const char = allCharacters[i]; - batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`; - batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`; - const genForm = document.getElementById('generate-form'); const fd = new FormData(); fd.append('character_slug', char.slug); fd.append('action', 'preview'); try { - progressContainer.classList.remove('d-none'); - progressBar.style.width = '100%'; progressBar.textContent = ''; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - progressLabel.textContent = `${char.name}: Queuing…`; - const resp = await fetch(genForm.getAttribute('action'), { + const resp = await fetch(form.getAttribute('action'), { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); - if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - currentJobId = data.job_id; - const jobResult = await waitForJob(currentJobId); - currentJobId = null; - if (jobResult.result && jobResult.result.image_url) { - addToPreviewGallery(jobResult.result.image_url, char.name); - previewImg.src = jobResult.result.image_url; - if (previewCard) previewCard.classList.remove('d-none'); - } - } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; } - finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } + if (!data.error) pending.push({ char, jobId: data.job_id }); + } catch (err) { console.error(`Submit error for ${char.name}:`, err); } } + + // Phase 2: poll all in parallel + batchLabel.textContent = `0 / ${pending.length} complete`; + let done = 0; + const total = pending.length; + await Promise.all(pending.map(({ char, jobId }) => + waitForJob(jobId).then(result => { + done++; + batchBar.style.width = `${Math.round((done / total) * 100)}%`; + batchLabel.textContent = `${done} / ${total} complete`; + if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name); + }).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); }) + )); + batchBar.style.width = '100%'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; generateAllBtn.disabled = false; @@ -387,7 +382,7 @@ stopAllBtn.addEventListener('click', () => { stopBatch = true; stopAllBtn.classList.add('d-none'); - batchLabel.textContent = 'Stopping after current generation...'; + batchLabel.textContent = 'Stopping after current submissions...'; }); // JSON Editor diff --git a/templates/checkpoints/index.html b/templates/checkpoints/index.html index 5643b44..b5eeac0 100644 --- a/templates/checkpoints/index.html +++ b/templates/checkpoints/index.html @@ -2,7 +2,7 @@ {% block content %}
-

Checkpoint Gallery

+

Checkpoint Library

diff --git a/templates/detail.html b/templates/detail.html index 7686a90..2e6eb7e 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -17,7 +17,7 @@
{% if character.image_path %} - {{ character.name }} + {{ character.name }} {% else %} No Image Attached {% endif %} @@ -44,35 +44,20 @@
- {% if preview_image %} -
-
- Latest Preview -
- -
-
-
-
- Preview -
-
-
- {% else %} -
-
- Latest Preview +
+
+ Selected Preview
- + +
- Preview + Preview
- {% endif %}
@@ -103,7 +88,7 @@

{{ character.name }}

Edit Profile
- Back to Gallery + Back to Library
@@ -227,9 +212,27 @@ const progressLabel = document.getElementById('progress-label'); const previewCard = document.getElementById('preview-card'); const previewImg = document.getElementById('preview-img'); + const previewPath = document.getElementById('preview-path'); + const replaceBtn = document.getElementById('replace-cover-btn'); + const previewHeader = document.getElementById('preview-card-header'); let currentJobId = null; - let currentAction = null; + + function selectPreview(relativePath, imageUrl) { + if (!relativePath) return; + previewImg.src = imageUrl; + previewPath.value = relativePath; + replaceBtn.disabled = false; + previewCard.classList.remove('d-none'); + previewHeader.classList.replace('bg-secondary', 'bg-success'); + previewCard.classList.replace('border-secondary', 'border-success'); + } + + // Clicking any image with data-preview-path selects it into the preview pane + document.addEventListener('click', e => { + const img = e.target.closest('img[data-preview-path]'); + if (img) selectPreview(img.dataset.previewPath, img.src); + }); async function waitForJob(jobId) { return new Promise((resolve, reject) => { @@ -254,55 +257,35 @@ } form.addEventListener('submit', async (e) => { - // Only intercept generate actions const submitter = e.submitter; - if (!submitter || submitter.value !== 'preview') { - return; - } - + if (!submitter || submitter.value !== 'preview') return; e.preventDefault(); - - currentAction = submitter.value; + const formData = new FormData(form); - formData.append('action', currentAction); - - // UI Reset + formData.append('action', 'preview'); + progressContainer.classList.remove('d-none'); progressBar.style.width = '100%'; progressBar.textContent = ''; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); progressLabel.textContent = 'Queuing…'; - + try { const response = await fetch(form.getAttribute('action'), { - method: 'POST', - body: formData, + method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); - const data = await response.json(); - - if (data.error) { - alert('Error: ' + data.error); - progressContainer.classList.add('d-none'); - return; - } - + if (data.error) { alert('Error: ' + data.error); return; } + currentJobId = data.job_id; progressLabel.textContent = 'Queued…'; - - // Wait for the background worker to finish const jobResult = await waitForJob(currentJobId); currentJobId = null; - // Image is already saved — just display it - if (jobResult.result && jobResult.result.image_url) { - previewImg.src = jobResult.result.image_url; - if (previewCard) previewCard.classList.remove('d-none'); - const replaceBtn = document.getElementById('replace-cover-btn'); - if (replaceBtn) replaceBtn.disabled = false; + if (jobResult.result?.image_url) { + selectPreview(jobResult.result.relative_path, jobResult.result.image_url); } - } catch (err) { console.error(err); alert('Generation failed: ' + err.message); @@ -312,8 +295,7 @@ } }); }); - - // Image modal function + function showImage(src) { document.getElementById('modalImage').src = src; } diff --git a/templates/detailers/detail.html b/templates/detailers/detail.html index e8826ac..d6d2d2f 100644 --- a/templates/detailers/detail.html +++ b/templates/detailers/detail.html @@ -56,7 +56,7 @@
{% if detailer.image_path %} - {{ detailer.name }} + {{ detailer.name }} {% else %}
No Image Attached @@ -121,35 +121,20 @@
- {% if preview_image %} -
-
- Latest Preview +
+
+ Selected Preview
- + +
- Preview + Preview
- {% else %} -
-
- Latest Preview -
- -
-
-
-
- Preview -
-
-
- {% endif %}
@@ -162,7 +147,7 @@
@@ -257,7 +242,8 @@ class="img-fluid rounded" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" onclick="showImage(this.src)" - data-bs-toggle="modal" data-bs-target="#imageModal"> + data-bs-toggle="modal" data-bs-target="#imageModal" + data-preview-path="{{ img }}">
{% else %} @@ -283,21 +269,31 @@ const progressLabel = document.getElementById('progress-label'); const previewCard = document.getElementById('preview-card'); const previewImg = document.getElementById('preview-img'); + const previewPath = document.getElementById('preview-path'); + const replaceBtn = document.getElementById('replace-cover-btn'); + const previewHeader = document.getElementById('preview-card-header'); const charSelect = document.getElementById('character_select'); const charContext = document.getElementById('character-context'); const actionSelect = document.getElementById('action_select'); - - // Toggle character context info + charSelect.addEventListener('change', () => { - if (charSelect.value && charSelect.value !== '__random__') { - charContext.classList.remove('d-none'); - } else { - charContext.classList.add('d-none'); - } + charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__'); }); - let currentJobId = null; - let currentAction = null; + function selectPreview(relativePath, imageUrl) { + if (!relativePath) return; + previewImg.src = imageUrl; + previewPath.value = relativePath; + replaceBtn.disabled = false; + previewCard.classList.remove('d-none'); + previewHeader.classList.replace('bg-secondary', 'bg-success'); + previewCard.classList.replace('border-secondary', 'border-success'); + } + + document.addEventListener('click', e => { + const img = e.target.closest('img[data-preview-path]'); + if (img) selectPreview(img.dataset.previewPath, img.src); + }); async function waitForJob(jobId) { return new Promise((resolve, reject) => { @@ -307,8 +303,7 @@ const data = await resp.json(); if (data.status === 'done') { clearInterval(poll); resolve(data); } else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); } - else if (data.status === 'processing') progressLabel.textContent = 'Generating…'; - else progressLabel.textContent = 'Queued…'; + else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…'; } catch (err) { console.error('Poll error:', err); } }, 1500); }); @@ -318,9 +313,8 @@ const submitter = e.submitter; if (!submitter || submitter.value !== 'preview') return; e.preventDefault(); - currentAction = submitter.value; const formData = new FormData(form); - formData.append('action', currentAction); + formData.append('action', 'preview'); progressContainer.classList.remove('d-none'); progressBar.style.width = '100%'; progressBar.textContent = ''; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); @@ -330,26 +324,21 @@ method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await response.json(); - if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; } - currentJobId = data.job_id; - const jobResult = await waitForJob(currentJobId); - currentJobId = null; - if (jobResult.result && jobResult.result.image_url) { - previewImg.src = jobResult.result.image_url; - if (previewCard) previewCard.classList.remove('d-none'); - const replaceBtn = document.getElementById('replace-cover-btn'); - if (replaceBtn) replaceBtn.disabled = false; + if (data.error) { alert('Error: ' + data.error); return; } + progressLabel.textContent = 'Queued…'; + const jobResult = await waitForJob(data.job_id); + if (jobResult.result?.image_url) { + selectPreview(jobResult.result.relative_path, jobResult.result.image_url); + addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, ''); } } catch (err) { console.error(err); alert('Generation failed: ' + err.message); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } }); - // Batch: Generate All Characters const allCharacters = [ {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% endfor %} ]; - let stopBatch = false; const generateAllBtn = document.getElementById('generate-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn'); @@ -357,7 +346,7 @@ const batchLabel = document.getElementById('batch-label'); const batchBar = document.getElementById('batch-bar'); - function addToPreviewGallery(imageUrl, charName) { + function addToPreviewGallery(imageUrl, relativePath, charName) { const gallery = document.getElementById('preview-gallery'); const placeholder = document.getElementById('gallery-empty'); if (placeholder) placeholder.remove(); @@ -368,8 +357,9 @@ style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" onclick="showImage(this.src)" data-bs-toggle="modal" data-bs-target="#imageModal" + data-preview-path="${relativePath}" title="${charName}"> -
${charName}
+ ${charName ? `
${charName}
` : ''}
`; gallery.insertBefore(col, gallery.firstChild); const badge = document.querySelector('#previews-tab .badge'); @@ -384,12 +374,13 @@ stopAllBtn.classList.remove('d-none'); batchProgress.classList.remove('d-none'); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); - for (let i = 0; i < allCharacters.length; i++) { + + const genForm = document.getElementById('generate-form'); + const formAction = genForm.getAttribute('action'); + batchLabel.textContent = 'Queuing all characters…'; + const pending = []; + for (const char of allCharacters) { if (stopBatch) break; - const char = allCharacters[i]; - batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`; - batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`; - const genForm = document.getElementById('generate-form'); const fd = new FormData(); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); fd.append('character_slug', char.slug); @@ -398,26 +389,25 @@ fd.append('extra_negative', document.getElementById('extra_negative').value); fd.append('action', 'preview'); try { - progressContainer.classList.remove('d-none'); - progressBar.style.width = '100%'; progressBar.textContent = ''; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - progressLabel.textContent = `${char.name}: Queuing…`; - const resp = await fetch(genForm.getAttribute('action'), { - method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } - }); + const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); - if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - currentJobId = data.job_id; - const jobResult = await waitForJob(currentJobId); - currentJobId = null; - if (jobResult.result && jobResult.result.image_url) { - addToPreviewGallery(jobResult.result.image_url, char.name); - previewImg.src = jobResult.result.image_url; - if (previewCard) previewCard.classList.remove('d-none'); - } - } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; } - finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } + if (!data.error) pending.push({ char, jobId: data.job_id }); + } catch (err) { console.error(`Submit error for ${char.name}:`, err); } } + + batchBar.style.width = '0%'; + let done = 0; + const total = pending.length; + batchLabel.textContent = `0 / ${total} complete`; + await Promise.all(pending.map(({ char, jobId }) => + waitForJob(jobId).then(result => { + done++; + batchBar.style.width = `${Math.round((done / total) * 100)}%`; + batchLabel.textContent = `${done} / ${total} complete`; + if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name); + }).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); }) + )); + batchBar.style.width = '100%'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; generateAllBtn.disabled = false; @@ -428,10 +418,9 @@ stopAllBtn.addEventListener('click', () => { stopBatch = true; stopAllBtn.classList.add('d-none'); - batchLabel.textContent = 'Stopping after current generation...'; + batchLabel.textContent = 'Stopping…'; }); - // JSON Editor initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}'); }); diff --git a/templates/detailers/index.html b/templates/detailers/index.html index f027204..778df34 100644 --- a/templates/detailers/index.html +++ b/templates/detailers/index.html @@ -2,7 +2,7 @@ {% block content %}
-

Detailer Gallery

+

Detailer Library

diff --git a/templates/gallery.html b/templates/gallery.html index 3ac78ee..60f0f6e 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -2,7 +2,7 @@ {% block content %}
-

Gallery +

Image Gallery {{ total }} image{{ 's' if total != 1 else '' }}

diff --git a/templates/index.html b/templates/index.html index 1535e42..80d64eb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,7 +2,7 @@ {% block content %}
-

Character Gallery

+

Character Library

diff --git a/templates/layout.html b/templates/layout.html index 66a40d9..c092edd 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -26,7 +26,7 @@
+ Character Generator - Gallery + Image Gallery Settings
@@ -48,19 +48,6 @@
-
-
- Default checkpoint: - - Saved -
-
-
{% with messages = get_flashed_messages() %} {% if messages %} @@ -129,22 +116,44 @@