diff --git a/CLAUDE.md b/CLAUDE.md index f362eec..e4a25ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,10 +298,10 @@ All generation routes use the background job queue. Frontend polls: Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes. ### Utilities -- `POST /set_default_checkpoint` — save default checkpoint to session -- `GET /get_missing_{characters,outfits,actions,scenes}` — AJAX: list items without cover images +- `POST /set_default_checkpoint` — save default checkpoint to session and persist to `comfy_workflow.json` +- `GET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}` — AJAX: list items without cover images (sorted by display name) - `POST /generate_missing` — batch generate covers for all characters missing one (uses job queue) -- `POST /clear_all_covers` / `clear_all_{outfit,action,scene}_covers` +- `POST /clear_all_covers` / `clear_all_{outfit,action,scene,style,detailer,look,checkpoint}_covers` - `GET /gallery` — global image gallery browsing `static/uploads/` - `GET/POST /settings` — LLM provider configuration - `POST /resource///delete` — soft (JSON only) or hard (JSON + safetensors) delete @@ -321,6 +321,11 @@ Image retrieval is handled server-side by the `_make_finalize()` callback; there - Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template. - **No `{% block head %}` exists** in layout.html — do not try to use it. - Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `{"job_id": ...}` response, then polls `/api/queue//status` every ~1.5 seconds until `status == "done"`. The server-side worker handles all ComfyUI polling and image saving via the `_make_finalize()` callback. There are no client-facing finalize HTTP routes. +- **Batch generation** (library pages): Uses a two-phase pattern: + 1. **Queue phase**: All jobs are submitted upfront via sequential fetch calls, collecting job IDs + 2. **Poll phase**: All jobs are polled concurrently via `Promise.all()`, updating UI as each completes + 3. **Progress tracking**: Displays currently processing items in real-time using a `Set` to track active jobs + 4. **Sorting**: All batch operations sort items by display `name` (not `filename`) for better UX --- diff --git a/app.py b/app.py index b7d4dac..1ab1c8d 100644 --- a/app.py +++ b/app.py @@ -47,12 +47,16 @@ Session(app) # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- +log_level_str = os.environ.get('LOG_LEVEL', 'INFO').upper() +log_level = getattr(logging, log_level_str, logging.INFO) + logging.basicConfig( - level=logging.INFO, + level=log_level, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S', ) logger = logging.getLogger('gaze') +logger.setLevel(log_level) # --------------------------------------------------------------------------- # Generation Job Queue @@ -117,11 +121,43 @@ def _queue_worker(): with _job_queue_lock: job['status'] = 'processing' - logger.info("Job started: [%s] %s", job['id'][:8], job['label']) + logger.info("=" * 80) + logger.info("JOB STARTED: [%s] %s", job['id'][:8], job['label']) + logger.info("Job created at: %s", time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(job['created_at']))) + + # Log workflow summary before sending to ComfyUI + workflow = job['workflow'] + logger.info("Workflow summary:") + logger.info(" Checkpoint: %s", workflow.get('4', {}).get('inputs', {}).get('ckpt_name', '(not set)')) + logger.info(" Seed: %s", workflow.get('3', {}).get('inputs', {}).get('seed', '(not set)')) + logger.info(" Resolution: %sx%s", + workflow.get('5', {}).get('inputs', {}).get('width', '?'), + workflow.get('5', {}).get('inputs', {}).get('height', '?')) + logger.info(" Sampler: %s / %s (steps=%s, cfg=%s)", + workflow.get('3', {}).get('inputs', {}).get('sampler_name', '?'), + workflow.get('3', {}).get('inputs', {}).get('scheduler', '?'), + workflow.get('3', {}).get('inputs', {}).get('steps', '?'), + workflow.get('3', {}).get('inputs', {}).get('cfg', '?')) + + # Log active LoRAs + active_loras = [] + for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]: + if node_id in workflow: + lora_name = workflow[node_id]["inputs"].get("lora_name", "") + if lora_name: + strength = workflow[node_id]["inputs"].get("strength_model", "?") + active_loras.append(f"{label_str}:{lora_name.split('/')[-1]}@{strength}") + logger.info(" Active LoRAs: %s", ' | '.join(active_loras) if active_loras else '(none)') + + # Log prompts + logger.info(" Positive prompt: %s", workflow.get('6', {}).get('inputs', {}).get('text', '(not set)')[:200]) + logger.info(" Negative prompt: %s", workflow.get('7', {}).get('inputs', {}).get('text', '(not set)')[:200]) + logger.info("=" * 80) try: with app.app_context(): # Send workflow to ComfyUI + logger.info("Sending workflow to ComfyUI...") prompt_response = queue_prompt(job['workflow']) if 'prompt_id' not in prompt_response: raise Exception(f"ComfyUI rejected job: {prompt_response.get('error', 'unknown error')}") @@ -134,11 +170,19 @@ def _queue_worker(): # Poll until done (max ~10 minutes) max_retries = 300 finished = False + poll_count = 0 + logger.info("Polling ComfyUI for completion (max %d retries, 2s interval)...", max_retries) while max_retries > 0: history = get_history(comfy_id) if comfy_id in history: finished = True + logger.info("Generation completed after %d polls (%d seconds)", + poll_count, poll_count * 2) break + poll_count += 1 + if poll_count % 10 == 0: # Log every 20 seconds + logger.info("Still waiting for generation... (%d polls, %d seconds elapsed)", + poll_count, poll_count * 2) time.sleep(2) max_retries -= 1 @@ -152,10 +196,14 @@ def _queue_worker(): with _job_queue_lock: job['status'] = 'done' - logger.info("Job done: [%s] %s", job['id'][:8], job['label']) + logger.info("=" * 80) + logger.info("JOB COMPLETED: [%s] %s", job['id'][:8], job['label']) + logger.info("=" * 80) except Exception as e: - logger.exception("Job failed: [%s] %s — %s", job['id'][:8], job['label'], e) + logger.error("=" * 80) + logger.exception("JOB FAILED: [%s] %s — %s", job['id'][:8], job['label'], e) + logger.error("=" * 80) with _job_queue_lock: job['status'] = 'failed' job['error'] = str(e) @@ -185,28 +233,59 @@ def _make_finalize(category, slug, db_model_class=None, action=None): action — 'replace' → update DB; None → always update; anything else → skip """ def _finalize(comfy_prompt_id, job): + logger.debug("=" * 80) + logger.debug("FINALIZE - Starting finalization for prompt ID: %s", comfy_prompt_id) + logger.debug("Category: %s, Slug: %s, Action: %s", category, slug, action) + history = get_history(comfy_prompt_id) outputs = history[comfy_prompt_id]['outputs'] - for node_output in outputs.values(): + + logger.debug("Processing outputs from %d node(s)", len(outputs)) + for node_id, node_output in outputs.items(): + logger.debug(" Node %s: %s", node_id, list(node_output.keys())) if 'images' in node_output: + logger.debug(" Found %d image(s) in node %s", len(node_output['images']), node_id) image_info = node_output['images'][0] + logger.debug(" Image info: filename=%s, subfolder=%s, type=%s", + image_info['filename'], image_info['subfolder'], image_info['type']) + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + folder = os.path.join(app.config['UPLOAD_FOLDER'], f"{category}/{slug}") os.makedirs(folder, exist_ok=True) filename = f"gen_{int(time.time())}.png" - with open(os.path.join(folder, filename), 'wb') as f: + full_path = os.path.join(folder, filename) + + logger.debug(" Saving image to: %s", full_path) + with open(full_path, 'wb') as f: f.write(image_data) + logger.info("Image saved: %s (%d bytes)", full_path, len(image_data)) + relative_path = f"{category}/{slug}/{filename}" job['result'] = { 'image_url': f'/static/uploads/{relative_path}', 'relative_path': relative_path, } + if db_model_class and (action is None or action == 'replace'): + logger.debug(" Updating database: %s.image_path = %s", db_model_class.__name__, relative_path) obj = db_model_class.query.filter_by(slug=slug).first() if obj: obj.image_path = relative_path db.session.commit() + logger.debug(" Database updated successfully") + else: + logger.warning(" Object not found in database: %s(slug=%s)", db_model_class.__name__, slug) + else: + logger.debug(" Skipping database update (db_model_class=%s, action=%s)", + db_model_class.__name__ if db_model_class else None, action) + + logger.debug("FINALIZE - Completed successfully") + logger.debug("=" * 80) return + + logger.warning("FINALIZE - No images found in outputs!") + logger.debug("=" * 80) return _finalize @@ -402,8 +481,38 @@ def inject_default_checkpoint(): @app.route('/set_default_checkpoint', methods=['POST']) def set_default_checkpoint(): - session['default_checkpoint'] = request.form.get('checkpoint_path', '') + checkpoint_path = request.form.get('checkpoint_path', '') + session['default_checkpoint'] = checkpoint_path session.modified = True + + # Persist to database Settings so it survives across server restarts + try: + settings = Settings.query.first() + if not settings: + settings = Settings() + db.session.add(settings) + settings.default_checkpoint = checkpoint_path + db.session.commit() + logger.info("Default checkpoint saved to database: %s", checkpoint_path) + except Exception as e: + logger.error(f"Failed to persist checkpoint to database: {e}") + db.session.rollback() + + # Also persist to comfy_workflow.json for backwards compatibility + try: + workflow_path = 'comfy_workflow.json' + with open(workflow_path, 'r') as f: + workflow = json.load(f) + + # Update node 4 (CheckpointLoaderSimple) with the new checkpoint + if '4' in workflow and 'inputs' in workflow['4']: + workflow['4']['inputs']['ckpt_name'] = checkpoint_path + + with open(workflow_path, 'w') as f: + json.dump(workflow, f, indent=2) + except Exception as e: + logger.error(f"Failed to persist checkpoint to workflow file: {e}") + return {'status': 'ok'} @@ -455,6 +564,44 @@ def api_status_mcp(): pass return {'status': 'error'} + +@app.route('/api/status/llm') +def api_status_llm(): + """Return whether the configured LLM provider is reachable.""" + try: + settings = Settings.query.first() + if not settings: + return {'status': 'error', 'message': 'Settings not configured'} + + is_local = settings.llm_provider != 'openrouter' + + if not is_local: + # Check OpenRouter + if not settings.openrouter_api_key: + return {'status': 'error', 'message': 'API key not configured'} + + # Try to fetch models list as a lightweight check + headers = { + "Authorization": f"Bearer {settings.openrouter_api_key}", + } + resp = requests.get("https://openrouter.ai/api/v1/models", headers=headers, timeout=5) + if resp.ok: + return {'status': 'ok', 'provider': 'OpenRouter'} + else: + # Check local provider (Ollama or LMStudio) + if not settings.local_base_url: + return {'status': 'error', 'message': 'Base URL not configured'} + + # Try to reach the models endpoint + url = f"{settings.local_base_url.rstrip('/')}/models" + resp = requests.get(url, timeout=5) + if resp.ok: + return {'status': 'ok', 'provider': settings.llm_provider.title()} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + return {'status': 'error'} + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} _LORA_DEFAULTS = { @@ -622,6 +769,44 @@ def _append_background(prompts, character=None): bg = f"{primary_color} simple background" if primary_color else "simple background" prompts['main'] = f"{prompts['main']}, {bg}" +def _count_look_assignments(): + """Return a dict mapping look_id to the count of characters it's assigned to.""" + # Looks are assigned via the character_id field in the Look model + assignment_counts = {} + looks = Look.query.all() + for look in looks: + if look.character_id: + assignment_counts[look.look_id] = 1 # Each look is assigned to at most one character + else: + assignment_counts[look.look_id] = 0 + return assignment_counts + +def _count_outfit_lora_assignments(): + """Return a dict mapping outfit LoRA filename to the count of characters using it.""" + assignment_counts = {} + characters = Character.query.all() + + for character in characters: + # Check character's own LoRA (in case it's actually an outfit LoRA) + char_lora = character.data.get('lora', {}).get('lora_name', '') + if char_lora and 'Clothing' in char_lora: + assignment_counts[char_lora] = assignment_counts.get(char_lora, 0) + 1 + + # Check all wardrobe outfits for LoRA references + wardrobe = character.data.get('wardrobe', {}) + # Handle both nested (new) and flat (legacy) wardrobe formats + if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): + # New nested format - check each outfit + for outfit_name, outfit_data in wardrobe.items(): + if isinstance(outfit_data, dict): + outfit_lora = outfit_data.get('lora', {}) + if isinstance(outfit_lora, dict): + lora_name = outfit_lora.get('lora_name', '') + if lora_name: + assignment_counts[lora_name] = assignment_counts.get(lora_name, 0) + 1 + + return assignment_counts + def build_prompt(data, selected_fields=None, default_fields=None, active_outfit='default'): def is_selected(section, key): # Priority: @@ -745,21 +930,104 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit= "hand": _dedup_tags(", ".join(hand_parts)) } +def _ensure_checkpoint_loaded(checkpoint_path): + """Check if the desired checkpoint is loaded in ComfyUI, and force reload if not.""" + if not checkpoint_path: + return + + try: + # Get currently loaded checkpoint from ComfyUI history + url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') + resp = requests.get(f'{url}/history', timeout=3) + if resp.ok: + history = resp.json() + if history: + latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', '')) + nodes = latest.get('prompt', [None, None, {}])[2] + loaded_ckpt = nodes.get('4', {}).get('inputs', {}).get('ckpt_name') + + # If the loaded checkpoint matches what we want, no action needed + if loaded_ckpt == checkpoint_path: + logger.info(f"Checkpoint {checkpoint_path} already loaded in ComfyUI") + return + + # Checkpoint doesn't match or couldn't determine - force unload all models + logger.info(f"Forcing ComfyUI to unload models to ensure {checkpoint_path} loads") + requests.post(f'{url}/free', json={'unload_models': True}, timeout=5) + except Exception as e: + logger.warning(f"Failed to check/force checkpoint reload: {e}") + def queue_prompt(prompt_workflow, client_id=None): + # Ensure the checkpoint in the workflow is loaded in ComfyUI + checkpoint_path = prompt_workflow.get('4', {}).get('inputs', {}).get('ckpt_name') + _ensure_checkpoint_loaded(checkpoint_path) + p = {"prompt": prompt_workflow} if client_id: p["client_id"] = client_id + + # Log the full request being sent to ComfyUI + logger.debug("=" * 80) + logger.debug("COMFYUI REQUEST - Sending prompt to %s/prompt", app.config['COMFYUI_URL']) + logger.debug("Checkpoint: %s", checkpoint_path) + logger.debug("Client ID: %s", client_id if client_id else "(none)") + logger.debug("Full workflow JSON:") + logger.debug(json.dumps(prompt_workflow, indent=2)) + logger.debug("=" * 80) + data = json.dumps(p).encode('utf-8') response = requests.post(f"{app.config['COMFYUI_URL']}/prompt", data=data) - return response.json() + response_json = response.json() + + # Log the response from ComfyUI + logger.debug("COMFYUI RESPONSE - Status: %s", response.status_code) + logger.debug("Response JSON: %s", json.dumps(response_json, indent=2)) + if 'prompt_id' in response_json: + logger.info("ComfyUI accepted prompt with ID: %s", response_json['prompt_id']) + else: + logger.error("ComfyUI rejected prompt: %s", response_json) + logger.debug("=" * 80) + + return response_json def get_history(prompt_id): response = requests.get(f"{app.config['COMFYUI_URL']}/history/{prompt_id}") - return response.json() + history_json = response.json() + + # Log detailed history response for debugging + if prompt_id in history_json: + logger.debug("=" * 80) + logger.debug("COMFYUI HISTORY - Prompt ID: %s", prompt_id) + logger.debug("Status: %s", response.status_code) + + # Extract key information from the history + prompt_data = history_json[prompt_id] + if 'status' in prompt_data: + logger.debug("Generation status: %s", prompt_data['status']) + + if 'outputs' in prompt_data: + logger.debug("Outputs available: %s", list(prompt_data['outputs'].keys())) + for node_id, output in prompt_data['outputs'].items(): + if 'images' in output: + logger.debug(" Node %s produced %d image(s)", node_id, len(output['images'])) + for img in output['images']: + logger.debug(" - %s (subfolder: %s, type: %s)", + img.get('filename'), img.get('subfolder'), img.get('type')) + + logger.debug("Full history response:") + logger.debug(json.dumps(history_json, indent=2)) + logger.debug("=" * 80) + else: + logger.debug("History not yet available for prompt ID: %s", prompt_id) + + return history_json def get_image(filename, subfolder, folder_type): data = {"filename": filename, "subfolder": subfolder, "type": folder_type} + logger.debug("Fetching image from ComfyUI: filename=%s, subfolder=%s, type=%s", + filename, subfolder, folder_type) response = requests.get(f"{app.config['COMFYUI_URL']}/view", params=data) + logger.debug("Image retrieved: %d bytes (status: %s)", len(response.content), response.status_code) return response.content from sqlalchemy.orm.attributes import flag_modified @@ -2218,30 +2486,87 @@ def _log_workflow_prompts(label, workflow): """Log the final assembled ComfyUI prompts in a consistent, readable block.""" sep = "=" * 72 active_loras = [] + lora_details = [] + + # Collect detailed LoRA information for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]: if node_id in workflow: name = workflow[node_id]["inputs"].get("lora_name", "") if name: - w = workflow[node_id]["inputs"].get("strength_model", "?") - active_loras.append(f"{label_str}:{name.split('/')[-1]}@{w:.3f}" if isinstance(w, float) else f"{label_str}:{name.split('/')[-1]}@{w}") + strength_model = workflow[node_id]["inputs"].get("strength_model", "?") + strength_clip = workflow[node_id]["inputs"].get("strength_clip", "?") + + # Short version for summary + if isinstance(strength_model, float): + active_loras.append(f"{label_str}:{name.split('/')[-1]}@{strength_model:.3f}") + else: + active_loras.append(f"{label_str}:{name.split('/')[-1]}@{strength_model}") + + # Detailed version + lora_details.append(f" Node {node_id} ({label_str}): {name}") + lora_details.append(f" strength_model={strength_model}, strength_clip={strength_clip}") + + # Extract VAE information + vae_info = "(integrated)" + if '21' in workflow: + vae_info = workflow['21']['inputs'].get('vae_name', '(custom)') + + # Extract adetailer information + adetailer_info = [] + for node_id, node_name in [("11", "Face"), ("13", "Hand")]: + if node_id in workflow: + adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, " + f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, " + f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}") + face_text = workflow.get('14', {}).get('inputs', {}).get('text', '') hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '') + lines = [ sep, f" WORKFLOW PROMPTS [{label}]", sep, - f" Checkpoint : {workflow['4']['inputs'].get('ckpt_name', '(not set)')}", - f" Seed : {workflow['3']['inputs'].get('seed', '(not set)')}", - f" Resolution : {workflow['5']['inputs'].get('width', '?')} x {workflow['5']['inputs'].get('height', '?')}", - f" Sampler : {workflow['3']['inputs'].get('sampler_name', '?')} / {workflow['3']['inputs'].get('scheduler', '?')} steps={workflow['3']['inputs'].get('steps', '?')} cfg={workflow['3']['inputs'].get('cfg', '?')}", - f" LoRAs : {' | '.join(active_loras) if active_loras else '(none)'}", - f" [+] Positive : {workflow['6']['inputs'].get('text', '')}", - f" [-] Negative : {workflow['7']['inputs'].get('text', '')}", + " MODEL CONFIGURATION:", + f" Checkpoint : {workflow['4']['inputs'].get('ckpt_name', '(not set)')}", + f" VAE : {vae_info}", + "", + " GENERATION SETTINGS:", + f" Seed : {workflow['3']['inputs'].get('seed', '(not set)')}", + f" Resolution : {workflow['5']['inputs'].get('width', '?')} x {workflow['5']['inputs'].get('height', '?')}", + f" Sampler : {workflow['3']['inputs'].get('sampler_name', '?')} / {workflow['3']['inputs'].get('scheduler', '?')}", + f" Steps : {workflow['3']['inputs'].get('steps', '?')}", + f" CFG Scale : {workflow['3']['inputs'].get('cfg', '?')}", + f" Denoise : {workflow['3']['inputs'].get('denoise', '1.0')}", ] + + # Add LoRA details + if active_loras: + lines.append("") + lines.append(" LORA CONFIGURATION:") + lines.extend(lora_details) + else: + lines.append("") + lines.append(" LORA CONFIGURATION: (none)") + + # Add adetailer details + if adetailer_info: + lines.append("") + lines.append(" ADETAILER CONFIGURATION:") + lines.extend(adetailer_info) + + # Add prompts + lines.extend([ + "", + " PROMPTS:", + f" [+] Positive : {workflow['6']['inputs'].get('text', '')}", + f" [-] Negative : {workflow['7']['inputs'].get('text', '')}", + ]) + if face_text: - lines.append(f" [F] Face : {face_text}") + lines.append(f" [F] Face : {face_text}") if hand_text: - lines.append(f" [H] Hand : {hand_text}") + lines.append(f" [H] Hand : {hand_text}") + lines.append(sep) logger.info("\n%s", "\n".join(lines)) @@ -2258,9 +2583,16 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega if "15" in workflow: workflow["15"]["inputs"]["text"] = workflow["15"]["inputs"]["text"].replace("{{HAND_PROMPT}}", prompts["hand"]) - # 2. Update Checkpoint + # 2. Update Checkpoint - always set one, fall back to default if not provided + if not checkpoint: + default_ckpt, default_ckpt_data = _get_default_checkpoint() + checkpoint = default_ckpt + if not checkpoint_data: + checkpoint_data = default_ckpt_data if checkpoint: workflow["4"]["inputs"]["ckpt_name"] = checkpoint + else: + raise ValueError("No checkpoint specified and no default checkpoint configured") # 3. Handle LoRAs - Node 16 for character, Node 17 for outfit, Node 18 for action, Node 19 for style/detailer # Start with direct checkpoint connections @@ -2394,18 +2726,38 @@ def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_nega return workflow def _get_default_checkpoint(): - """Return (checkpoint_path, checkpoint_data) from the session default, or (None, None).""" + """Return (checkpoint_path, checkpoint_data) from the database Settings, session, or fall back to workflow file.""" ckpt_path = session.get('default_checkpoint') + + # If no session checkpoint, try to read from database Settings + if not ckpt_path: + settings = Settings.query.first() + if settings and settings.default_checkpoint: + ckpt_path = settings.default_checkpoint + logger.debug("Loaded default checkpoint from database: %s", ckpt_path) + + # If still no checkpoint, try to read from the workflow file + if not ckpt_path: + try: + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + ckpt_path = workflow.get('4', {}).get('inputs', {}).get('ckpt_name') + logger.debug("Loaded default checkpoint from workflow file: %s", ckpt_path) + except Exception: + pass + if not ckpt_path: return None, None + ckpt = Checkpoint.query.filter_by(checkpoint_path=ckpt_path).first() if not ckpt: - return None, None + # Checkpoint path exists but not in DB - return path with empty data + return ckpt_path, {} return ckpt.checkpoint_path, ckpt.data or {} @app.route('/get_missing_characters') def get_missing_characters(): - missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.filename).all() + missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.name).all() return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} @app.route('/clear_all_covers', methods=['POST']) @@ -2420,7 +2772,7 @@ def clear_all_covers(): def generate_missing(): missing = Character.query.filter( (Character.image_path == None) | (Character.image_path == '') - ).order_by(Character.filename).all() + ).order_by(Character.name).all() if not missing: flash("No characters missing cover images.") @@ -2490,7 +2842,7 @@ def save_defaults(slug): @app.route('/get_missing_outfits') def get_missing_outfits(): - missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.filename).all() + missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.name).all() return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]} @app.route('/clear_all_outfit_covers', methods=['POST']) @@ -2503,7 +2855,7 @@ def clear_all_outfit_covers(): @app.route('/get_missing_actions') def get_missing_actions(): - missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.filename).all() + missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.name).all() return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]} @app.route('/clear_all_action_covers', methods=['POST']) @@ -2516,7 +2868,7 @@ def clear_all_action_covers(): @app.route('/get_missing_scenes') def get_missing_scenes(): - missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.filename).all() + missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.name).all() return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} @app.route('/clear_all_scene_covers', methods=['POST']) @@ -2947,7 +3299,8 @@ def get_missing_presets(): @app.route('/outfits') def outfits_index(): outfits = Outfit.query.order_by(Outfit.name).all() - return render_template('outfits/index.html', outfits=outfits) + lora_assignments = _count_outfit_lora_assignments() + return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments) @app.route('/outfits/rescan', methods=['POST']) def rescan_outfits(): @@ -4293,12 +4646,12 @@ def replace_style_cover_from_preview(slug): @app.route('/get_missing_styles') def get_missing_styles(): - missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.filename).all() + missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.name).all() return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} @app.route('/get_missing_detailers') def get_missing_detailers(): - missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.filename).all() + missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.name).all() return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]} @app.route('/clear_all_detailer_covers', methods=['POST']) @@ -4321,7 +4674,7 @@ def clear_all_style_covers(): def generate_missing_styles(): missing = Style.query.filter( (Style.image_path == None) | (Style.image_path == '') - ).order_by(Style.filename).all() + ).order_by(Style.name).all() if not missing: flash("No styles missing cover images.") @@ -5673,7 +6026,7 @@ def save_checkpoint_json(slug): @app.route('/get_missing_checkpoints') def get_missing_checkpoints(): - missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.checkpoint_path).all() + missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.name).all() return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} @app.route('/clear_all_checkpoint_covers', methods=['POST']) @@ -5790,7 +6143,8 @@ def bulk_create_checkpoints(): @app.route('/looks') def looks_index(): looks = Look.query.order_by(Look.name).all() - return render_template('looks/index.html', looks=looks) + look_assignments = _count_look_assignments() + return render_template('looks/index.html', looks=looks, look_assignments=look_assignments) @app.route('/looks/rescan', methods=['POST']) def rescan_looks(): @@ -6036,7 +6390,7 @@ def create_look(): @app.route('/get_missing_looks') def get_missing_looks(): - missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.filename).all() + missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.name).all() return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]} @app.route('/clear_all_look_covers', methods=['POST']) @@ -6932,6 +7286,7 @@ if __name__ == '__main__': ('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'"), + ('default_checkpoint', "VARCHAR(500)"), ] for col_name, col_type in columns_to_add: try: @@ -6950,6 +7305,15 @@ if __name__ == '__main__': db.session.commit() print("Created default settings") + # Log the default checkpoint on startup + settings = Settings.query.first() + if settings and settings.default_checkpoint: + logger.info("=" * 80) + logger.info("DEFAULT CHECKPOINT loaded from database: %s", settings.default_checkpoint) + logger.info("=" * 80) + else: + logger.info("No default checkpoint set in database") + sync_characters() sync_outfits() sync_actions() diff --git a/comfy_workflow.json b/comfy_workflow.json index 173f9f2..2423bd8 100644 --- a/comfy_workflow.json +++ b/comfy_workflow.json @@ -16,7 +16,7 @@ }, "4": { "inputs": { - "ckpt_name": "Noob/oneObsession_v19Atypical.safetensors" + "ckpt_name": "" }, "class_type": "CheckpointLoaderSimple" }, diff --git a/data/characters/delinquent_mother_flim13.json b/data/characters/delinquent_mother_flim13.json index 5385761..782b5f1 100644 --- a/data/characters/delinquent_mother_flim13.json +++ b/data/characters/delinquent_mother_flim13.json @@ -1,6 +1,6 @@ { "character_id": "delinquent_mother_flim13", - "character_name": "Delinquent Mother", + "character_name": "Gyaru Mother", "identity": { "base_specs": "1girl, milf, gyaru, tall", "hair": "blonde hair, long hair", @@ -47,4 +47,4 @@ "Original", "flim13" ] -} +} \ No newline at end of file diff --git a/data/characters/kda_all_out_akali.json b/data/characters/kda_all_out_akali.json index 6cdf881..69519f3 100644 --- a/data/characters/kda_all_out_akali.json +++ b/data/characters/kda_all_out_akali.json @@ -22,7 +22,7 @@ "default": { "full_body": "", "headwear": "", - "top": "black crop top, blue and silver motorcycle jacket", + "top": "black crop top, blue and silver jacket", "bottom": "black leather pants", "legwear": "", "footwear": "blue sneakers", @@ -49,4 +49,4 @@ "KDA", "K-Pop" ] -} +} \ No newline at end of file diff --git a/data/checkpoints/illustrious_zukinewcuteill_newv20.json b/data/checkpoints/illustrious_zukinewcuteill_newv20.json index 2449fbd..5ff28be 100644 --- a/data/checkpoints/illustrious_zukinewcuteill_newv20.json +++ b/data/checkpoints/illustrious_zukinewcuteill_newv20.json @@ -1,11 +1,11 @@ { - "checkpoint_path": "Illustrious/zukiNewCuteILL_newV20.safetensors", - "checkpoint_name": "zukiNewCuteILL_newV20.safetensors", - "base_positive": "anime", "base_negative": "text, logo", - "steps": 25, + "base_positive": "anime, cute, loli, moe", "cfg": 5, + "checkpoint_name": "zukiNewCuteILL_newV20.safetensors", + "checkpoint_path": "Illustrious/zukiNewCuteILL_newV20.safetensors", + "sampler_name": "euler_ancestral", "scheduler": "normal", - "sampler_name": "euler_ancestral", + "steps": 25, "vae": "integrated" } \ No newline at end of file diff --git a/data/clothing/golddripnunchaindresslingerieill.json b/data/clothing/golddripnunchaindresslingerieill.json index 5c9812b..0727550 100644 --- a/data/clothing/golddripnunchaindresslingerieill.json +++ b/data/clothing/golddripnunchaindresslingerieill.json @@ -1,23 +1,13 @@ { - "outfit_id": "golddripnunchaindresslingerieill", - "outfit_name": "Golddripnunchaindresslingerieill", - "wardrobe": { - "full_body": "revealing nun dress with gold drip accents", - "headwear": "nun veil, jewelry", - "top": "lingerie top, gold chains", - "bottom": "skirt, gold trim", - "legwear": "thighhighs, garter straps", - "footwear": "heels", - "hands": "", - "accessories": "gold chains, cross necklace, body chain" - }, "lora": { - "lora_name": "Illustrious/Clothing/GoldDripNunChainDressLingerieILL.safetensors", + "lora_name": "", + "lora_triggers": "", "lora_weight": 0.8, - "lora_triggers": "GoldDripNunChainDressLingerieILL", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_weight_max": 0.8, + "lora_weight_min": 0.8 }, + "outfit_id": "golddripnunchaindresslingerieill", + "outfit_name": "Nun (with Gold)", "tags": [ "nun", "veil", @@ -33,5 +23,15 @@ "dripping", "gold", "body_chain" - ] -} + ], + "wardrobe": { + "accessories": "gold chains, cross necklace, body chain", + "bottom": "skirt, gold trim", + "footwear": "heels", + "full_body": "revealing nun dress with gold drip accents", + "hands": "", + "headwear": "nun veil, jewelry", + "legwear": "thighhighs, garter straps", + "top": "lingerie top, gold chains" + } +} \ No newline at end of file diff --git a/data/looks/jn_tron_bonne_illus.json b/data/looks/jn_tron_bonne_illus.json index 6379827..0b136d6 100644 --- a/data/looks/jn_tron_bonne_illus.json +++ b/data/looks/jn_tron_bonne_illus.json @@ -1,9 +1,7 @@ { + "character_id": null, "look_id": "jn_tron_bonne_illus", "look_name": "Jn Tron Bonne Illus", - "character_id": "", - "positive": "tron_bonne_(mega_man), brown_hair, short_hair, spiked_hair, goggles_on_head, pink_jacket, crop_top, midriff, navel, skull_print, pink_shorts, boots, large_earrings, servbot_(mega_man)", - "negative": "pubic hair, 3d, realistic, loli, censored, bad anatomy, sketch, monochrome", "lora": { "lora_name": "Illustrious/Looks/JN_Tron_Bonne_Illus.safetensors", "lora_weight": 0.8, @@ -11,6 +9,8 @@ "lora_weight_min": 0.8, "lora_weight_max": 0.8 }, + "negative": "pubic hair, 3d, realistic, loli, censored, bad anatomy, sketch, monochrome", + "positive": "tron_bonne_(mega_man), brown_hair, short_hair, spiked_hair, purple cropped jacket, pantyhose, metal panties, short pink dress, boots, skull_earrings, servbot_(mega_man)", "tags": [ "tron_bonne_(mega_man)", "goggles_on_head", @@ -26,4 +26,4 @@ "brown_hair", "short_hair" ] -} +} \ No newline at end of file diff --git a/data/presets/example_01.json b/data/presets/example_01.json index 24d80a6..46ab3e7 100644 --- a/data/presets/example_01.json +++ b/data/presets/example_01.json @@ -55,7 +55,7 @@ } }, "style": { - "style_id": null, + "style_id": "random", "use_lora": true }, "scene": { @@ -81,4 +81,4 @@ "checkpoint_path": null }, "tags": [] -} +} \ No newline at end of file diff --git a/data/styles/7b_style.json b/data/styles/7b_style.json index c616b8c..defb3b1 100644 --- a/data/styles/7b_style.json +++ b/data/styles/7b_style.json @@ -2,8 +2,8 @@ "style_id": "7b_style", "style_name": "7B Dream", "style": { - "artist_name": "7b_Dream", - "artistic_style": "3d" + "artist_name": "7b", + "artistic_style": "3d, blender, semi-realistic" }, "lora": { "lora_name": "Illustrious/Styles/7b-style.safetensors", @@ -12,4 +12,4 @@ "lora_weight_min": 1.0, "lora_weight_max": 1.0 } -} +} \ No newline at end of file diff --git a/data/styles/bckiwi_3d_style_il_2_7_rank16_fp16.json b/data/styles/bckiwi_3d_style_il_2_7_rank16_fp16.json deleted file mode 100644 index 25e09da..0000000 --- a/data/styles/bckiwi_3d_style_il_2_7_rank16_fp16.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "style_id": "bckiwi_3d_style_il_2_7_rank16_fp16", - "style_name": "Bckiwi 3D Style Il 2 7 Rank16 Fp16", - "style": { - "artist_name": "", - "artistic_style": "" - }, - "lora": { - "lora_name": "Illustrious/Styles/BCkiwi_3D_style_IL_2.7_rank16_fp16.safetensors", - "lora_weight": 1.0, - "lora_triggers": "BCkiwi_3D_style_IL_2.7_rank16_fp16", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 - } -} diff --git a/docker-compose.yml b/docker-compose.yml index dbc7364..81e7a5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,9 @@ services: # ComfyUI runs on the Docker host COMFYUI_URL: http://10.0.0.200:8188 # Compose manages danbooru-mcp — skip the app's auto-start logic SKIP_MCP_AUTOSTART: "true" + # Enable debug logging + FLASK_DEBUG: "1" + LOG_LEVEL: "DEBUG" volumes: # Persistent data - ./data:/app/data diff --git a/launch.sh b/launch.sh old mode 100644 new mode 100755 diff --git a/models.py b/models.py index 67e8309..3377cb8 100644 --- a/models.py +++ b/models.py @@ -154,6 +154,8 @@ class Settings(db.Model): 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') + # Default checkpoint path (persisted across server restarts) + default_checkpoint = db.Column(db.String(500), nullable=True) def __repr__(self): return '' diff --git a/static/style.css b/static/style.css index 37cafa6..8966358 100644 --- a/static/style.css +++ b/static/style.css @@ -356,6 +356,34 @@ h5, h6 { color: var(--text); } object-fit: cover; } +/* Assignment badge — shows count of characters using this resource */ +.assignment-badge { + position: absolute; + top: 8px; + right: 8px; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dim) 100%); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + min-width: 22px; + height: 22px; + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 6px; + line-height: 1; + z-index: 2; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + border: 1.5px solid rgba(255, 255, 255, 0.15); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.character-card:hover .assignment-badge { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(139, 126, 255, 0.5); +} + /* Generator result container */ #result-container { background-color: var(--bg-raised) !important; diff --git a/templates/actions/index.html b/templates/actions/index.html index 152a4b3..75431a0 100644 --- a/templates/actions/index.html +++ b/templates/actions/index.html @@ -160,7 +160,10 @@ statusText.textContent = `0 / ${jobs.length} done`; let completed = 0; + let currentItem = ''; await Promise.all(jobs.map(async ({ item, jobId }) => { + currentItem = item.name; + itemNameText.textContent = `Processing: ${currentItem}`; try { const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) { diff --git a/templates/checkpoints/index.html b/templates/checkpoints/index.html index b5eeac0..d763848 100644 --- a/templates/checkpoints/index.html +++ b/templates/checkpoints/index.html @@ -115,53 +115,68 @@ regenAllBtn.disabled = true; container.classList.remove('d-none'); - let completed = 0; - for (const ckpt of missing) { - const percent = Math.round((completed / missing.length) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`; - ckptNameText.textContent = `Current: ${ckpt.name}`; - nodeStatus.textContent = 'Queuing…'; - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = ''; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + // Phase 1: Queue all jobs upfront + progressBar.style.width = '100%'; + progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + nodeStatus.textContent = 'Queuing…'; + const jobs = []; + for (const ckpt of missing) { + statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}…`; try { const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ 'character_slug': '__random__' }), + body: new URLSearchParams({ character_slug: '__random__' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentJobId = genData.job_id; + if (genData.job_id) jobs.push({ item: ckpt, jobId: genData.job_id }); + } catch (err) { + console.error(`Failed to queue ${ckpt.name}:`, err); + } + } - const jobResult = await waitForJob(currentJobId); - currentJobId = null; + // Phase 2: Poll all concurrently + progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); + progressBar.style.width = '0%'; + progressBar.textContent = '0%'; + statusText.textContent = `0 / ${jobs.length} done`; + let completed = 0; + let currentItem = ''; + await Promise.all(jobs.map(async ({ item, jobId }) => { + currentItem = item.name; + ckptNameText.textContent = `Processing: ${currentItem}`; + try { + const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) { - const img = document.getElementById(`img-${ckpt.slug}`); - const noImgSpan = document.getElementById(`no-img-${ckpt.slug}`); + const img = document.getElementById(`img-${item.slug}`); + const noImgSpan = document.getElementById(`no-img-${item.slug}`); if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); } if (noImgSpan) noImgSpan.classList.add('d-none'); } } catch (err) { - console.error(`Failed for ${ckpt.name}:`, err); - currentJobId = null; + console.error(`Failed for ${item.name}:`, err); } completed++; - } + const pct = Math.round((completed / jobs.length) * 100); + progressBar.style.width = `${pct}%`; + progressBar.textContent = `${pct}%`; + statusText.textContent = `${completed} / ${jobs.length} done`; + })); progressBar.style.width = '100%'; progressBar.textContent = '100%'; - statusText.textContent = 'Batch Generation Complete!'; + statusText.textContent = 'Batch Checkpoint Generation Complete!'; ckptNameText.textContent = ''; nodeStatus.textContent = 'Done'; + stepProgressText.textContent = ''; taskProgressBar.style.width = '0%'; taskProgressBar.textContent = ''; batchBtn.disabled = false; regenAllBtn.disabled = false; - setTimeout(() => { container.classList.add('d-none'); }, 5000); + setTimeout(() => container.classList.add('d-none'), 5000); } batchBtn.addEventListener('click', async () => { diff --git a/templates/detailers/index.html b/templates/detailers/index.html index 778df34..78e2430 100644 --- a/templates/detailers/index.html +++ b/templates/detailers/index.html @@ -162,7 +162,10 @@ statusText.textContent = `0 / ${jobs.length} done`; let completed = 0; + let currentItem = ''; await Promise.all(jobs.map(async ({ item, jobId }) => { + currentItem = item.name; + detailerNameText.textContent = `Processing: ${currentItem}`; try { const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) { diff --git a/templates/index.html b/templates/index.html index 80d64eb..ebf4c4a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -161,7 +161,10 @@ statusText.textContent = `0 / ${jobs.length} done`; let completed = 0; + let currentItem = ''; await Promise.all(jobs.map(async ({ item, jobId }) => { + currentItem = item.name; + charNameText.textContent = `Processing: ${currentItem}`; try { const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) { diff --git a/templates/layout.html b/templates/layout.html index dfcb546..c8e44f4 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -45,6 +45,10 @@ MCP + + + LLM + @@ -343,6 +347,7 @@ const services = [ { id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' }, { id: 'status-mcp', url: '/api/status/mcp', label: 'Danbooru MCP' }, + { id: 'status-llm', url: '/api/status/llm', label: 'LLM' }, ]; function setStatus(id, label, ok) { diff --git a/templates/looks/index.html b/templates/looks/index.html index 0e4aa9c..17690a7 100644 --- a/templates/looks/index.html +++ b/templates/looks/index.html @@ -59,6 +59,9 @@ {{ look.name }} No Image {% endif %} + {% if look_assignments.get(look.look_id, 0) > 0 %} + {{ look_assignments.get(look.look_id, 0) }} + {% endif %}
{{ look.name }}
@@ -106,6 +109,22 @@ const stepProgressText = document.getElementById('current-step-progress'); let currentJobId = null; + let queuePollInterval = null; + + async function updateCurrentJobLabel() { + try { + const resp = await fetch('/api/queue'); + const data = await resp.json(); + const processingJob = data.jobs.find(j => j.status === 'processing'); + if (processingJob) { + itemNameText.textContent = `Processing: ${processingJob.label}`; + } else { + itemNameText.textContent = ''; + } + } catch (err) { + console.error('Failed to fetch queue:', err); + } + } async function waitForJob(jobId) { return new Promise((resolve, reject) => { @@ -136,30 +155,42 @@ regenAllBtn.disabled = true; container.classList.remove('d-none'); - let completed = 0; - for (const item of missing) { - const percent = Math.round((completed / missing.length) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - statusText.textContent = `Batch Generating Looks: ${completed + 1} / ${missing.length}`; - itemNameText.textContent = `Current: ${item.name}`; - nodeStatus.textContent = "Queuing…"; - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = ''; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + // Phase 1: Queue all jobs upfront + progressBar.style.width = '100%'; + progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + nodeStatus.textContent = 'Queuing…'; + const jobs = []; + for (const item of missing) { + statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}…`; try { const genResp = await fetch(`/look/${item.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }), + body: new URLSearchParams({ action: 'replace' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentJobId = genData.job_id; + if (genData.job_id) jobs.push({ item, jobId: genData.job_id }); + } catch (err) { + console.error(`Failed to queue ${item.name}:`, err); + } + } - const jobResult = await waitForJob(currentJobId); - currentJobId = null; + // Phase 2: Poll all concurrently + progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); + progressBar.style.width = '0%'; + progressBar.textContent = '0%'; + statusText.textContent = `0 / ${jobs.length} done`; + // Start polling queue for current job label + queuePollInterval = setInterval(updateCurrentJobLabel, 1000); + updateCurrentJobLabel(); // Initial update + + let completed = 0; + await Promise.all(jobs.map(async ({ item, jobId }) => { + try { + const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) { const img = document.getElementById(`img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`); @@ -168,22 +199,31 @@ } } catch (err) { console.error(`Failed for ${item.name}:`, err); - currentJobId = null; } completed++; + const pct = Math.round((completed / jobs.length) * 100); + progressBar.style.width = `${pct}%`; + progressBar.textContent = `${pct}%`; + statusText.textContent = `${completed} / ${jobs.length} done`; + })); + + // Stop polling queue + if (queuePollInterval) { + clearInterval(queuePollInterval); + queuePollInterval = null; } progressBar.style.width = '100%'; progressBar.textContent = '100%'; - statusText.textContent = "Batch Look Generation Complete!"; - itemNameText.textContent = ""; - nodeStatus.textContent = "Done"; - stepProgressText.textContent = ""; + statusText.textContent = 'Batch Look Generation Complete!'; + itemNameText.textContent = ''; + nodeStatus.textContent = 'Done'; + stepProgressText.textContent = ''; taskProgressBar.style.width = '0%'; taskProgressBar.textContent = ''; batchBtn.disabled = false; regenAllBtn.disabled = false; - setTimeout(() => { container.classList.add('d-none'); }, 5000); + setTimeout(() => container.classList.add('d-none'), 5000); } batchBtn.addEventListener('click', async () => { diff --git a/templates/outfits/index.html b/templates/outfits/index.html index 0592338..862b7ee 100644 --- a/templates/outfits/index.html +++ b/templates/outfits/index.html @@ -59,6 +59,9 @@ {{ outfit.name }} No Image {% endif %} + {% if outfit.data.lora and outfit.data.lora.lora_name and lora_assignments.get(outfit.data.lora.lora_name, 0) > 0 %} + {{ lora_assignments.get(outfit.data.lora.lora_name, 0) }} + {% endif %}
{{ outfit.name }}
@@ -160,7 +163,10 @@ statusText.textContent = `0 / ${jobs.length} done`; let completed = 0; + let currentItem = ''; await Promise.all(jobs.map(async ({ item, jobId }) => { + currentItem = item.name; + itemNameText.textContent = `Processing: ${currentItem}`; try { const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) { diff --git a/templates/scenes/index.html b/templates/scenes/index.html index 544f018..a93cf36 100644 --- a/templates/scenes/index.html +++ b/templates/scenes/index.html @@ -160,7 +160,10 @@ statusText.textContent = `0 / ${jobs.length} done`; let completed = 0; + let currentItem = ''; await Promise.all(jobs.map(async ({ item, jobId }) => { + currentItem = item.name; + itemNameText.textContent = `Processing: ${currentItem}`; try { const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) { diff --git a/templates/styles/index.html b/templates/styles/index.html index f23cbef..8923075 100644 --- a/templates/styles/index.html +++ b/templates/styles/index.html @@ -160,7 +160,10 @@ statusText.textContent = `0 / ${jobs.length} done`; let completed = 0; + let currentItem = ''; await Promise.all(jobs.map(async ({ item, jobId }) => { + currentItem = item.name; + styleNameText.textContent = `Processing: ${currentItem}`; try { const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) {