From 3c828a170fdf10d2910bc57f896db9c40a386746 Mon Sep 17 00:00:00 2001 From: Aodhan Collins Date: Tue, 3 Mar 2026 02:32:50 +0000 Subject: [PATCH] Add background job queue system for generation - Implements sequential job queue with background worker thread (_enqueue_job, _queue_worker) - All generate routes now return job_id instead of prompt_id; frontend polls /api/queue//status - Queue management UI in navbar with live badge, job list, pause/resume/remove controls - Fix: replaced url_for() calls inside finalize callbacks with direct string paths (url_for raises RuntimeError without request context in background threads) - Batch cover generation now uses two-phase pattern: queue all jobs upfront, then poll concurrently via Promise.all so page navigation doesn't interrupt the process - Strengths gallery sweep migrated to same two-phase pattern; sgStop() cancels queued jobs server-side - LoRA weight randomisation via lora_weight_min/lora_weight_max already present in _resolve_lora_weight Co-Authored-By: Claude Sonnet 4.6 --- app.py | 651 +++++++++++++++++----- static/style.css | 68 +++ templates/actions/detail.html | 216 ++----- templates/actions/index.html | 144 ++--- templates/checkpoints/detail.html | 157 ++---- templates/checkpoints/index.html | 78 +-- templates/detail.html | 167 ++---- templates/detailers/detail.html | 201 ++----- templates/detailers/index.html | 145 ++--- templates/generator.html | 98 +--- templates/index.html | 147 ++--- templates/layout.html | 173 ++++++ templates/looks/detail.html | 104 +--- templates/looks/index.html | 82 +-- templates/outfits/detail.html | 194 ++----- templates/outfits/index.html | 144 ++--- templates/partials/strengths_gallery.html | 125 ++--- templates/scenes/detail.html | 205 ++----- templates/scenes/index.html | 150 ++--- templates/styles/detail.html | 200 ++----- templates/styles/index.html | 148 ++--- 21 files changed, 1451 insertions(+), 2146 deletions(-) diff --git a/app.py b/app.py index 388612e..ca2b3d3 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,9 @@ import requests import random import asyncio import subprocess +import threading +import uuid +from collections import deque from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from flask import Flask, render_template, request, redirect, url_for, flash, session @@ -39,6 +42,201 @@ app.config['SESSION_PERMANENT'] = False db.init_app(app) Session(app) +# --------------------------------------------------------------------------- +# Generation Job Queue +# --------------------------------------------------------------------------- +# Each job is a dict: +# id — unique UUID string +# label — human-readable description (e.g. "Tifa Lockhart – preview") +# status — 'pending' | 'processing' | 'done' | 'failed' | 'paused' | 'removed' +# workflow — the fully-prepared ComfyUI workflow dict +# finalize_fn — callable(comfy_prompt_id, job) that saves the image; called after ComfyUI finishes +# error — error message string (when status == 'failed') +# result — dict with image_url etc. (set by finalize_fn on success) +# created_at — unix timestamp +# comfy_prompt_id — the prompt_id returned by ComfyUI (set when processing starts) + +_job_queue_lock = threading.Lock() +_job_queue = deque() # ordered list of job dicts (pending + paused + processing) +_job_history = {} # job_id -> job dict (all jobs ever added, for status lookup) +_queue_worker_event = threading.Event() # signals worker that a new job is available + + +def _enqueue_job(label, workflow, finalize_fn): + """Add a generation job to the queue. Returns the job dict.""" + job = { + 'id': str(uuid.uuid4()), + 'label': label, + 'status': 'pending', + 'workflow': workflow, + 'finalize_fn': finalize_fn, + 'error': None, + 'result': None, + 'created_at': time.time(), + 'comfy_prompt_id': None, + } + with _job_queue_lock: + _job_queue.append(job) + _job_history[job['id']] = job + _queue_worker_event.set() + return job + + +def _queue_worker(): + """Background thread: processes jobs from _job_queue sequentially.""" + while True: + _queue_worker_event.wait() + _queue_worker_event.clear() + + while True: + job = None + with _job_queue_lock: + # Find the first pending job + for j in _job_queue: + if j['status'] == 'pending': + job = j + break + + if job is None: + break # No pending jobs — go back to waiting + + # Mark as processing + with _job_queue_lock: + job['status'] = 'processing' + + try: + with app.app_context(): + # Send 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')}") + + comfy_id = prompt_response['prompt_id'] + with _job_queue_lock: + job['comfy_prompt_id'] = comfy_id + + # Poll until done (max ~10 minutes) + max_retries = 300 + finished = False + while max_retries > 0: + history = get_history(comfy_id) + if comfy_id in history: + finished = True + break + time.sleep(2) + max_retries -= 1 + + if not finished: + raise Exception("ComfyUI generation timed out") + + # Run the finalize callback (saves image to disk / DB) + # finalize_fn(comfy_prompt_id, job) — job is passed so callback can store result + job['finalize_fn'](comfy_id, job) + + with _job_queue_lock: + job['status'] = 'done' + + except Exception as e: + print(f"[Queue] Job {job['id']} failed: {e}") + with _job_queue_lock: + job['status'] = 'failed' + job['error'] = str(e) + + # Remove completed/failed jobs from the active queue (keep in history) + with _job_queue_lock: + try: + _job_queue.remove(job) + except ValueError: + pass # Already removed (e.g. by user) + + +# Start the background worker thread +_worker_thread = threading.Thread(target=_queue_worker, daemon=True, name='queue-worker') +_worker_thread.start() + + +# --------------------------------------------------------------------------- +# Queue API routes +# --------------------------------------------------------------------------- + +@app.route('/api/queue') +def api_queue_list(): + """Return the current queue as JSON.""" + with _job_queue_lock: + jobs = [ + { + 'id': j['id'], + 'label': j['label'], + 'status': j['status'], + 'error': j['error'], + 'created_at': j['created_at'], + } + for j in _job_queue + ] + return {'jobs': jobs, 'count': len(jobs)} + + +@app.route('/api/queue/count') +def api_queue_count(): + """Return just the count of active (non-done, non-failed) jobs.""" + with _job_queue_lock: + count = sum(1 for j in _job_queue if j['status'] in ('pending', 'processing', 'paused')) + return {'count': count} + + +@app.route('/api/queue//remove', methods=['POST']) +def api_queue_remove(job_id): + """Remove a pending or paused job from the queue.""" + with _job_queue_lock: + job = _job_history.get(job_id) + if not job: + return {'error': 'Job not found'}, 404 + if job['status'] == 'processing': + return {'error': 'Cannot remove a job that is currently processing'}, 400 + try: + _job_queue.remove(job) + except ValueError: + pass # Already not in queue + job['status'] = 'removed' + return {'status': 'ok'} + + +@app.route('/api/queue//pause', methods=['POST']) +def api_queue_pause(job_id): + """Toggle pause/resume on a pending job.""" + with _job_queue_lock: + job = _job_history.get(job_id) + if not job: + return {'error': 'Job not found'}, 404 + if job['status'] == 'pending': + job['status'] = 'paused' + elif job['status'] == 'paused': + job['status'] = 'pending' + _queue_worker_event.set() + else: + return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400 + return {'status': 'ok', 'new_status': job['status']} + + +@app.route('/api/queue//status') +def api_queue_job_status(job_id): + """Return the status of a specific job.""" + with _job_queue_lock: + job = _job_history.get(job_id) + if not job: + return {'error': 'Job not found'}, 404 + resp = { + 'id': job['id'], + 'label': job['label'], + 'status': job['status'], + 'error': job['error'], + 'comfy_prompt_id': job['comfy_prompt_id'], + } + if job.get('result'): + resp['result'] = job['result'] + return resp + + # Path to the danbooru-mcp docker-compose project, relative to this file. MCP_TOOLS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tools') MCP_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'danbooru-mcp') @@ -1284,7 +1482,6 @@ def generator(): checkpoint = request.form.get('checkpoint') custom_positive = request.form.get('positive_prompt', '') custom_negative = request.form.get('negative_prompt', '') - client_id = request.form.get('client_id') action_slugs = request.form.getlist('action_slugs') outfit_slugs = request.form.getlist('outfit_slugs') @@ -1336,44 +1533,33 @@ def generator(): ) print(f"Queueing generator prompt for {character.character_id}") - prompt_response = queue_prompt(workflow, client_id=client_id) - - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") - - prompt_id = prompt_response['prompt_id'] - + + _char_slug = character.slug + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{_char_slug}") + os.makedirs(char_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(char_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"characters/{_char_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + return + + label = f"Generator: {character.name}" + job = _enqueue_job(label, workflow, _finalize) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} - - flash("Generation started...") - - max_retries = 120 - while max_retries > 0: - history = get_history(prompt_id) - if prompt_id in history: - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{character.slug}") - os.makedirs(char_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(char_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - relative_path = f"characters/{character.slug}/{filename}" - return render_template('generator.html', - characters=characters, checkpoints=checkpoints, - actions=actions, outfits=outfits, scenes=scenes, - styles=styles, detailers=detailers, - generated_image=relative_path, selected_char=char_slug, selected_ckpt=checkpoint) - time.sleep(2) - max_retries -= 1 - flash("Generation timed out.") + return {'status': 'queued', 'job_id': job['id']} + + flash("Generation queued.") except Exception as e: print(f"Generator error: {e}") if request.headers.get('X-Requested-With') == 'XMLHttpRequest': @@ -2194,7 +2380,6 @@ def generate_image(slug): try: # Get action type action = request.form.get('action', 'preview') - client_id = request.form.get('client_id') # Get selected fields selected_fields = request.form.getlist('include_field') @@ -2202,17 +2387,45 @@ def generate_image(slug): # Save preferences session[f'prefs_{slug}'] = selected_fields - # Queue generation using helper - prompt_response = _queue_generation(character, action, selected_fields, client_id=client_id) + # Build workflow + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + prompts = build_prompt(character.data, selected_fields, character.default_fields, character.active_outfit) + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) + + # Finalize callback — runs in background thread after ComfyUI finishes + _action = action + _slug = slug + _char_name = character.name + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{_slug}") + os.makedirs(char_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(char_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"characters/{_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + if _action == 'replace': + char_obj = Character.query.filter_by(slug=_slug).first() + if char_obj: + char_obj.image_path = relative_path + db.session.commit() + return + + label = f"{character.name} – {action}" + job = _enqueue_job(label, workflow, _finalize) - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") - - prompt_id = prompt_response['prompt_id'] - - # Return JSON if AJAX request if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} + return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('detail', slug=slug)) @@ -2510,11 +2723,10 @@ def generate_outfit_image(slug): try: # Get action type action = request.form.get('action', 'preview') - client_id = request.form.get('client_id') - + # Get selected fields selected_fields = request.form.getlist('include_field') - + # Get selected character (if any) character_slug = request.form.get('character_slug', '') character = None @@ -2610,17 +2822,38 @@ def generate_outfit_image(slug): # Prepare workflow - pass both character and outfit for dual LoRA support ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, outfit=outfit, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - - prompt_response = queue_prompt(workflow, client_id=client_id) - - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") - - prompt_id = prompt_response['prompt_id'] - - # Return JSON if AJAX request + + _action = action + _slug = slug + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{_slug}") + os.makedirs(outfit_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(outfit_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"outfits/{_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + if _action == 'replace': + outfit_obj = Outfit.query.filter_by(slug=_slug).first() + if outfit_obj: + outfit_obj.image_path = relative_path + db.session.commit() + return + + char_label = character.name if character else 'no character' + label = f"Outfit: {outfit.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _finalize) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} + return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('outfit_detail', slug=slug)) @@ -3027,8 +3260,7 @@ def generate_action_image(slug): try: # Get action type action_type = request.form.get('action', 'preview') - client_id = request.form.get('client_id') - + # Get selected fields selected_fields = request.form.getlist('include_field') @@ -3209,16 +3441,38 @@ def generate_action_image(slug): # Prepare workflow ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - - prompt_response = queue_prompt(workflow, client_id=client_id) - - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") - - prompt_id = prompt_response['prompt_id'] - + + _action_type = action_type + _slug = slug + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{_slug}") + os.makedirs(action_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(action_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"actions/{_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + if _action_type == 'replace': + action_db = Action.query.filter_by(slug=_slug).first() + if action_db: + action_db.image_path = relative_path + db.session.commit() + return + + char_label = character.name if character else 'no character' + label = f"Action: {action_obj.name} ({char_label}) – {action_type}" + job = _enqueue_job(label, workflow, _finalize) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} + return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('action_detail', slug=slug)) @@ -3654,7 +3908,8 @@ def upload_style_image(slug): return redirect(url_for('style_detail', slug=slug)) -def _queue_style_generation(style_obj, character=None, selected_fields=None, client_id=None): +def _build_style_workflow(style_obj, character=None, selected_fields=None): + """Build and return a prepared ComfyUI workflow dict for a style generation.""" if character: combined_data = character.data.copy() combined_data['character_id'] = character.character_id @@ -3733,7 +3988,7 @@ def _queue_style_generation(style_obj, character=None, selected_fields=None, cli ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, style=style_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - return queue_prompt(workflow, client_id=client_id) + return workflow @app.route('/style//generate', methods=['POST']) def generate_style_image(slug): @@ -3742,7 +3997,6 @@ def generate_style_image(slug): try: # Get action type action = request.form.get('action', 'preview') - client_id = request.form.get('client_id') # Get selected fields selected_fields = request.form.getlist('include_field') @@ -3763,16 +4017,40 @@ def generate_style_image(slug): session[f'char_style_{slug}'] = character_slug session[f'prefs_style_{slug}'] = selected_fields - # Queue generation using helper - prompt_response = _queue_style_generation(style_obj, character, selected_fields, client_id=client_id) - - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") - - prompt_id = prompt_response['prompt_id'] - + # Build workflow using helper (returns workflow dict, not prompt_response) + workflow = _build_style_workflow(style_obj, character, selected_fields) + + _action = action + _slug = slug + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{_slug}") + os.makedirs(style_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(style_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"styles/{_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + if _action == 'replace': + style_db = Style.query.filter_by(slug=_slug).first() + if style_db: + style_db.image_path = relative_path + db.session.commit() + return + + char_label = character.name if character else 'no character' + label = f"Style: {style_obj.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _finalize) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} + return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('style_detail', slug=slug)) @@ -3899,7 +4177,8 @@ def generate_missing_styles(): style_slug = style_obj.slug try: print(f"Batch generating style: {style_obj.name} with character {character.name}") - prompt_response = _queue_style_generation(style_obj, character=character) + workflow = _build_style_workflow(style_obj, character=character) + prompt_response = queue_prompt(workflow) prompt_id = prompt_response['prompt_id'] max_retries = 120 @@ -4370,7 +4649,7 @@ def _queue_scene_generation(scene_obj, character=None, selected_fields=None, cli # For scene generation, we want to ensure Node 20 is handled in _prepare_workflow ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, scene=scene_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - return queue_prompt(workflow, client_id=client_id) + return workflow @app.route('/scene//generate', methods=['POST']) def generate_scene_image(slug): @@ -4379,7 +4658,6 @@ def generate_scene_image(slug): try: # Get action type action = request.form.get('action', 'preview') - client_id = request.form.get('client_id') # Get selected fields selected_fields = request.form.getlist('include_field') @@ -4400,16 +4678,40 @@ def generate_scene_image(slug): session[f'char_scene_{slug}'] = character_slug session[f'prefs_scene_{slug}'] = selected_fields - # Queue generation using helper - prompt_response = _queue_scene_generation(scene_obj, character, selected_fields, client_id=client_id) - - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") - - prompt_id = prompt_response['prompt_id'] - + # Build workflow using helper + workflow = _queue_scene_generation(scene_obj, character, selected_fields) + + _action = action + _slug = slug + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{_slug}") + os.makedirs(scene_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(scene_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"scenes/{_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + if _action == 'replace': + scene_db = Scene.query.filter_by(slug=_slug).first() + if scene_db: + scene_db.image_path = relative_path + db.session.commit() + return + + char_label = character.name if character else 'no character' + label = f"Scene: {scene_obj.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _finalize) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} + return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('scene_detail', slug=slug)) @@ -4920,7 +5222,7 @@ def _queue_detailer_generation(detailer_obj, character=None, selected_fields=Non ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, detailer=detailer_obj, action=action, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - return queue_prompt(workflow, client_id=client_id) + return workflow @app.route('/detailer//generate', methods=['POST']) def generate_detailer_image(slug): @@ -4928,8 +5230,7 @@ def generate_detailer_image(slug): try: # Get action type - action = request.form.get('action', 'preview') - client_id = request.form.get('client_id') + action_type = request.form.get('action', 'preview') # Get selected fields selected_fields = request.form.getlist('include_field') @@ -4948,7 +5249,7 @@ def generate_detailer_image(slug): # Get selected action (if any) action_slug = request.form.get('action_slug', '') - action = Action.query.filter_by(slug=action_slug).first() if action_slug else None + action_obj = Action.query.filter_by(slug=action_slug).first() if action_slug else None # Get additional prompts extra_positive = request.form.get('extra_positive', '').strip() @@ -4961,16 +5262,40 @@ def generate_detailer_image(slug): session[f'extra_neg_detailer_{slug}'] = extra_negative session[f'prefs_detailer_{slug}'] = selected_fields - # Queue generation using helper - prompt_response = _queue_detailer_generation(detailer_obj, character, selected_fields, client_id=client_id, action=action, extra_positive=extra_positive, extra_negative=extra_negative) - - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") - - prompt_id = prompt_response['prompt_id'] - + # Build workflow using helper + workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative) + + _action_type = action_type + _slug = slug + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{_slug}") + os.makedirs(detailer_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(detailer_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"detailers/{_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + if _action_type == 'replace': + detailer_db = Detailer.query.filter_by(slug=_slug).first() + if detailer_db: + detailer_db.image_path = relative_path + db.session.commit() + return + + char_label = character.name if character else 'no character' + label = f"Detailer: {detailer_obj.name} ({char_label}) – {action_type}" + job = _enqueue_job(label, workflow, _finalize) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} + return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('detailer_detail', slug=slug)) @@ -5316,7 +5641,8 @@ def _apply_checkpoint_settings(workflow, ckpt_data): return workflow -def _queue_checkpoint_generation(ckpt_obj, character=None, client_id=None): +def _build_checkpoint_workflow(ckpt_obj, character=None): + """Build and return a prepared ComfyUI workflow dict for a checkpoint generation.""" with open('comfy_workflow.json', 'r') as f: workflow = json.load(f) @@ -5344,14 +5670,12 @@ def _queue_checkpoint_generation(ckpt_obj, character=None, client_id=None): workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_obj.checkpoint_path, checkpoint_data=ckpt_obj.data or {}) - - return queue_prompt(workflow, client_id=client_id) + return workflow @app.route('/checkpoint//generate', methods=['POST']) def generate_checkpoint_image(slug): ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() try: - client_id = request.form.get('client_id') character_slug = request.form.get('character_slug', '') character = None if character_slug == '__random__': @@ -5363,14 +5687,37 @@ def generate_checkpoint_image(slug): character = Character.query.filter_by(slug=character_slug).first() session[f'char_checkpoint_{slug}'] = character_slug - prompt_response = _queue_checkpoint_generation(ckpt, character, client_id=client_id) + workflow = _build_checkpoint_workflow(ckpt, character) - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") + _slug = slug + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + ckpt_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{_slug}") + os.makedirs(ckpt_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(ckpt_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"checkpoints/{_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + ckpt_db = Checkpoint.query.filter_by(slug=_slug).first() + if ckpt_db: + ckpt_db.image_path = relative_path + db.session.commit() + return + + char_label = character.name if character else 'random' + label = f"Checkpoint: {ckpt.name} ({char_label})" + job = _enqueue_job(label, workflow, _finalize) - prompt_id = prompt_response['prompt_id'] if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} + 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}") @@ -5648,7 +5995,6 @@ def generate_look_image(slug): try: action = request.form.get('action', 'preview') - client_id = request.form.get('client_id') selected_fields = request.form.getlist('include_field') character_slug = request.form.get('character_slug', '') @@ -5718,13 +6064,37 @@ def generate_look_image(slug): workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data, look=look) - prompt_response = queue_prompt(workflow, client_id=client_id) - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") + _action = action + _slug = slug + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_id in outputs: + if 'images' in outputs[node_id]: + image_info = outputs[node_id]['images'][0] + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"looks/{_slug}") + os.makedirs(look_folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + file_path = os.path.join(look_folder, filename) + with open(file_path, 'wb') as f: + f.write(image_data) + relative_path = f"looks/{_slug}/{filename}" + image_url = f'/static/uploads/{relative_path}' + job['result'] = {'image_url': image_url, 'relative_path': relative_path} + if _action == 'replace': + look_db = Look.query.filter_by(slug=_slug).first() + if look_db: + look_db.image_path = relative_path + db.session.commit() + return + + char_label = character.name if character else 'no character' + label = f"Look: {look.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _finalize) - prompt_id = prompt_response['prompt_id'] if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'prompt_id': prompt_id} + return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('look_detail', slug=slug)) except Exception as e: @@ -6533,7 +6903,6 @@ def strengths_generate(category, slug): try: strength_value = float(request.form.get('strength_value', 1.0)) fixed_seed = int(request.form.get('seed', random.randint(1, 10**15))) - client_id = request.form.get('client_id', '') # Resolve character: prefer POST body value (reflects current page dropdown), # then fall back to session. @@ -6584,9 +6953,35 @@ def strengths_generate(category, slug): custom_negative=extra_negative ) - result = queue_prompt(workflow, client_id) - prompt_id = result.get('prompt_id', '') - return {'status': 'queued', 'prompt_id': prompt_id} + _category = category + _slug = slug + _strength_value = strength_value + _fixed_seed = fixed_seed + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id].get('outputs', {}) + img_data = None + for node_output in outputs.values(): + for img in node_output.get('images', []): + img_data = get_image(img['filename'], img.get('subfolder', ''), img.get('type', 'output')) + break + if img_data: + break + if not img_data: + raise Exception('no image in output') + strength_str = f"{_strength_value:.2f}".replace('.', '_') + upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], _category, _slug, 'strengths') + os.makedirs(upload_dir, exist_ok=True) + out_filename = f"strength_{strength_str}_seed_{_fixed_seed}.png" + out_path = os.path.join(upload_dir, out_filename) + with open(out_path, 'wb') as f: + f.write(img_data) + relative = f"{_category}/{_slug}/strengths/{out_filename}" + job['result'] = {'image_url': f"/static/uploads/{relative}", 'strength_value': _strength_value} + + label = f"Strengths: {entity.name} @ {strength_value:.2f}" + job = _enqueue_job(label, workflow, _finalize) + return {'status': 'queued', 'job_id': job['id']} except Exception as e: print(f"[Strengths] generate error: {e}") diff --git a/static/style.css b/static/style.css index 283dde6..052caf0 100644 --- a/static/style.css +++ b/static/style.css @@ -121,6 +121,74 @@ a:hover { color: #9d98ff; } opacity: 1; } +/* ---- Queue button in navbar ---- */ +.queue-btn { + position: relative; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1rem; + padding: 0.2rem 0.5rem; + border-radius: 6px; + transition: color 0.15s, background 0.15s; + line-height: 1; +} +.queue-btn:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.07); +} +.queue-btn-active { + color: var(--accent) !important; +} +.queue-badge { + position: absolute; + top: -2px; + right: -4px; + background: var(--accent); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + min-width: 16px; + height: 16px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; + line-height: 1; +} +.queue-icon { + font-size: 0.95rem; +} + +/* Queue status dots */ +.queue-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} +.queue-status-pending { background: var(--text-muted); } +.queue-status-processing { background: var(--warning); animation: pulse 1s infinite; } +.queue-status-paused { background: var(--text-dim); } +.queue-status-done { background: var(--success); } +.queue-status-failed { background: var(--danger); } +.queue-status-removed { background: var(--border); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Small button variant for queue actions */ +.btn-xs { + padding: 0.1rem 0.35rem; + font-size: 0.7rem; + border-radius: 4px; + line-height: 1.4; +} + /* ============================================================ Cards ============================================================ */ diff --git a/templates/actions/detail.html b/templates/actions/detail.html index 05d077e..003382f 100644 --- a/templates/actions/detail.html +++ b/templates/actions/detail.html @@ -326,187 +326,72 @@ } }); - // Generate a unique client ID - const clientId = 'action_detail_' + Math.random().toString(36).substring(2, 15); - - // ComfyUI WebSocket - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding Image", - "9": "Saving Image" - }; - - let currentPromptId = null; + let currentJobId = null; let currentAction = null; - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'status') { - if (!currentPromptId) { - const queueRemaining = msg.data.status.exec_info.queue_remaining; - if (queueRemaining > 0) { - progressLabel.textContent = `Queue position: ${queueRemaining}`; - } - } - } - else if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - - const nodeId = msg.data.node; - if (nodeId === null) { - // Execution finished via WebSocket - console.log('Finished via WebSocket'); - if (resolveCompletion) resolveCompletion(); - } else { - const nodeName = nodeNames[nodeId] || `Processing...`; - progressLabel.textContent = nodeName; - } - } - }); - - let resolveCompletion = null; - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveCompletion = checkResolve; - - // Fallback polling in case WebSocket is blocked (403) - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - console.log('Finished via Polling'); - checkResolve(); + 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…'; } - } catch (err) { console.error('Polling error:', err); } - }, 2000); + } catch (err) { console.error('Poll error:', err); } + }, 1500); }); } 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); - formData.append('client_id', clientId); - - // UI Reset progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = 'Starting...'; - + 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, - headers: { - 'X-Requested-With': 'XMLHttpRequest' - } - }); - - const data = await response.json(); - - if (data.error) { - alert('Error: ' + data.error); - progressContainer.classList.add('d-none'); - return; - } - - currentPromptId = data.prompt_id; - progressLabel.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - - // Wait for completion (WebSocket or Polling) - await waitForCompletion(currentPromptId); - - // Finalize - finalizeGeneration(currentPromptId, currentAction); - currentPromptId = null; - - } catch (err) { - console.error(err); - alert('Request failed'); - progressContainer.classList.add('d-none'); - } - }); - - async function finalizeGeneration(promptId, action) { - progressLabel.textContent = 'Saving image...'; - const url = `/action/{{ action.slug }}/finalize_generation/${promptId}`; - const formData = new FormData(); - formData.append('action', 'preview'); // Always save as preview - - try { - const response = await fetch(url, { - method: 'POST', - body: formData + method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await response.json(); - - if (data.success) { - // Update preview image - previewImg.src = data.image_url; + 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'); - - // Enable the replace cover button if it exists const replaceBtn = document.getElementById('replace-cover-btn'); - if (replaceBtn) { - replaceBtn.disabled = false; - // Check if there's a form to update - const form = replaceBtn.closest('form'); - if (form) { - form.action = `/action/{{ action.slug }}/replace_cover_from_preview`; - } - } - } else { - alert('Save failed: ' + data.error); + if (replaceBtn) replaceBtn.disabled = false; } } catch (err) { console.error(err); - alert('Finalize request failed'); + 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 %} ]; - const finalizeBaseUrl = '/action/{{ action.slug }}/finalize_generation'; let stopBatch = false; const generateAllBtn = document.getElementById('generate-all-btn'); @@ -542,54 +427,43 @@ 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++) { 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'); - fd.append('client_id', clientId); - try { progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = `${char.name}: Starting...`; - + progressBar.style.width = '100%'; + progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + progressLabel.textContent = `${char.name}: Queuing…`; const resp = await fetch(genForm.getAttribute('action'), { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - - currentPromptId = data.prompt_id; - await waitForCompletion(currentPromptId); - - progressLabel.textContent = 'Saving image...'; - const finalFD = new FormData(); - finalFD.append('action', 'preview'); - const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); - const finalData = await finalResp.json(); - if (finalData.success) { - addToPreviewGallery(finalData.image_url, char.name); - previewImg.src = finalData.image_url; + 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'); } - currentPromptId = null; } catch (err) { console.error(`Failed for ${char.name}:`, err); - currentPromptId = null; + currentJobId = null; } finally { progressContainer.classList.add('d-none'); + progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } } - batchBar.style.width = '100%'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; generateAllBtn.disabled = false; diff --git a/templates/actions/index.html b/templates/actions/index.html index c51e421..3597ce6 100644 --- a/templates/actions/index.html +++ b/templates/actions/index.html @@ -102,71 +102,18 @@ const itemNameText = document.getElementById('current-item-name'); const stepProgressText = document.getElementById('current-step-progress'); - const clientId = 'actions_batch_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding", - "9": "Saving" - }; - - let currentPromptId = null; - let resolveGeneration = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - stepProgressText.textContent = `${percent}%`; - taskProgressBar.style.width = `${percent}%`; - taskProgressBar.textContent = `${percent}%`; - taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveGeneration) resolveGeneration(); - } else { - nodeStatus.textContent = nodeNames[nodeId] || `Processing...`; - stepProgressText.textContent = ""; - if (nodeId !== "3") { - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - } - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveGeneration = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - checkResolve(); - } + 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') nodeStatus.textContent = 'Generating…'; + else nodeStatus.textContent = 'Queued…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -183,68 +130,65 @@ batchBtn.disabled = true; 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 Actions: ${completed + 1} / ${missing.length}`; - itemNameText.textContent = `Current: ${item.name}`; - nodeStatus.textContent = "Queuing..."; - - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = 'Queued'; - 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 { - // Random character for action preview const genResp = await fetch(`/action/${item.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ - 'action': 'replace', - 'client_id': clientId, - 'character_slug': '__random__' - }), + body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentPromptId = genData.prompt_id; + if (genData.job_id) jobs.push({ item, jobId: genData.job_id }); + } catch (err) { + console.error(`Failed to queue ${item.name}:`, err); + } + } - await waitForCompletion(currentPromptId); + // 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`; - const finResp = await fetch(`/action/${item.slug}/finalize_generation/${currentPromptId}`, { - method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }) - }); - const finData = await finResp.json(); - - if (finData.success) { + 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}`); - if (img) { - img.src = finData.image_url; - img.classList.remove('d-none'); - } + 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 ${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 Action Generation Complete!"; - itemNameText.textContent = ""; - nodeStatus.textContent = "Done"; - stepProgressText.textContent = ""; + statusText.textContent = 'Batch Action Generation Complete!'; + itemNameText.textContent = ''; + nodeStatus.textContent = 'Done'; 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/checkpoints/detail.html b/templates/checkpoints/detail.html index cb61293..3d3def3 100644 --- a/templates/checkpoints/detail.html +++ b/templates/checkpoints/detail.html @@ -261,52 +261,20 @@ const previewCard = document.getElementById('preview-card'); const previewImg = document.getElementById('preview-img'); - const clientId = 'checkpoint_detail_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); + let currentJobId = null; - const nodeNames = { - "3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", - "4": "Loading Models", "16": "Character LoRA", "17": "Outfit LoRA", - "18": "Action LoRA", "19": "Style/Detailer LoRA", "8": "Decoding Image", "9": "Saving Image" - }; - - let currentPromptId = null; - let resolveCompletion = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - if (msg.type === 'status') { - if (!currentPromptId) { - const q = msg.data.status.exec_info.queue_remaining; - if (q > 0) progressLabel.textContent = `Queue position: ${q}`; - } - } else if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const percent = Math.round((msg.data.value / msg.data.max) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - } else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveCompletion) resolveCompletion(); - } else { - progressLabel.textContent = nodeNames[nodeId] || 'Processing...'; - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { clearInterval(pollInterval); resolve(); }; - resolveCompletion = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') checkResolve(); + 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…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -314,76 +282,36 @@ const submitter = e.submitter; if (!submitter || submitter.value !== 'preview') return; e.preventDefault(); - const formData = new FormData(form); formData.append('action', 'preview'); - formData.append('client_id', clientId); - progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = 'Starting...'; - + 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, - headers: { 'X-Requested-With': 'XMLHttpRequest' } + 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; - } - - currentPromptId = data.prompt_id; - progressLabel.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - - await waitForCompletion(currentPromptId); - await finalizeGeneration(currentPromptId); - currentPromptId = null; - } catch (err) { - console.error(err); - alert('Request failed'); - progressContainer.classList.add('d-none'); - } - }); - - async function finalizeGeneration(promptId) { - progressLabel.textContent = 'Saving image...'; - const url = `/checkpoint/{{ ckpt.slug }}/finalize_generation/${promptId}`; - const formData = new FormData(); - formData.append('action', 'preview'); - - try { - const response = await fetch(url, { method: 'POST', body: formData }); - const data = await response.json(); - if (data.success) { - previewImg.src = data.image_url; + 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; - } else { - alert('Save failed: ' + data.error); } - } catch (err) { - console.error(err); - alert('Finalize request failed'); - } finally { - progressContainer.classList.add('d-none'); - } - } + } 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 %} ]; - const finalizeBaseUrl = '/checkpoint/{{ ckpt.slug }}/finalize_generation'; let stopBatch = false; const generateAllBtn = document.getElementById('generate-all-btn'); @@ -419,53 +347,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++) { 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'); - fd.append('client_id', clientId); - try { progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = `${char.name}: Starting...`; - + progressBar.style.width = '100%'; progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + progressLabel.textContent = `${char.name}: Queuing…`; const resp = await fetch(genForm.getAttribute('action'), { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - - currentPromptId = data.prompt_id; - await waitForCompletion(currentPromptId); - - progressLabel.textContent = 'Saving image...'; - const finalFD = new FormData(); - finalFD.append('action', 'preview'); - const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); - const finalData = await finalResp.json(); - if (finalData.success) { - addToPreviewGallery(finalData.image_url, char.name); - previewImg.src = finalData.image_url; + 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'); } - currentPromptId = null; - } catch (err) { - console.error(`Failed for ${char.name}:`, err); - currentPromptId = null; - } finally { - progressContainer.classList.add('d-none'); - } + } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; } + finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } } - batchBar.style.width = '100%'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; generateAllBtn.disabled = false; diff --git a/templates/checkpoints/index.html b/templates/checkpoints/index.html index d62595d..1cef050 100644 --- a/templates/checkpoints/index.html +++ b/templates/checkpoints/index.html @@ -84,55 +84,20 @@ const ckptNameText = document.getElementById('current-ckpt-name'); const stepProgressText = document.getElementById('current-step-progress'); - const clientId = 'checkpoints_batch_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); + let currentJobId = null; - const nodeNames = { - "3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", - "4": "Loading Models", "16": "Character LoRA", "17": "Outfit LoRA", - "18": "Action LoRA", "19": "Style/Detailer LoRA", "8": "Decoding", "9": "Saving" - }; - - let currentPromptId = null; - let resolveGeneration = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const percent = Math.round((msg.data.value / msg.data.max) * 100); - stepProgressText.textContent = `${percent}%`; - taskProgressBar.style.width = `${percent}%`; - taskProgressBar.textContent = `${percent}%`; - taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveGeneration) resolveGeneration(); - } else { - nodeStatus.textContent = nodeNames[nodeId] || 'Processing...'; - stepProgressText.textContent = ''; - if (nodeId !== '3') { - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - } - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { clearInterval(pollInterval); resolve(); }; - resolveGeneration = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') checkResolve(); + 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') nodeStatus.textContent = 'Generating…'; + else nodeStatus.textContent = 'Queued…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -157,36 +122,32 @@ progressBar.textContent = `${percent}%`; statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`; ckptNameText.textContent = `Current: ${ckpt.name}`; - nodeStatus.textContent = 'Queuing...'; + nodeStatus.textContent = 'Queuing…'; taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = 'Queued'; + taskProgressBar.textContent = ''; taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); try { const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ 'client_id': clientId, 'character_slug': '__random__' }), + body: new URLSearchParams({ 'character_slug': '__random__' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentPromptId = genData.prompt_id; + currentJobId = genData.job_id; - await waitForCompletion(currentPromptId); + const jobResult = await waitForJob(currentJobId); + currentJobId = null; - const finResp = await fetch(`/checkpoint/${ckpt.slug}/finalize_generation/${currentPromptId}`, { - method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }) - }); - const finData = await finResp.json(); - - if (finData.success) { + if (jobResult.result && jobResult.result.image_url) { const img = document.getElementById(`img-${ckpt.slug}`); const noImgSpan = document.getElementById(`no-img-${ckpt.slug}`); - if (img) { img.src = finData.image_url; img.classList.remove('d-none'); } + 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; } completed++; } @@ -196,7 +157,6 @@ statusText.textContent = 'Batch Generation Complete!'; ckptNameText.textContent = ''; nodeStatus.textContent = 'Done'; - stepProgressText.textContent = ''; taskProgressBar.style.width = '0%'; taskProgressBar.textContent = ''; batchBtn.disabled = false; diff --git a/templates/detail.html b/templates/detail.html index ca63ff2..7686a90 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -227,83 +227,29 @@ const progressLabel = document.getElementById('progress-label'); const previewCard = document.getElementById('preview-card'); const previewImg = document.getElementById('preview-img'); - - // Generate a unique client ID - const clientId = 'detail_view_' + Math.random().toString(36).substring(2, 15); - // ComfyUI WebSocket - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding Image", - "9": "Saving Image" - }; - - let currentPromptId = null; + let currentJobId = null; let currentAction = null; - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'status') { - if (!currentPromptId) { - const queueRemaining = msg.data.status.exec_info.queue_remaining; - if (queueRemaining > 0) { - progressLabel.textContent = `Queue position: ${queueRemaining}`; - } - } - } - else if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - - const nodeId = msg.data.node; - if (nodeId === null) { - // Execution finished via WebSocket - console.log('Finished via WebSocket'); - if (resolveCompletion) resolveCompletion(); - } else { - const nodeName = nodeNames[nodeId] || `Processing...`; - progressLabel.textContent = nodeName; - } - } - }); - - let resolveCompletion = null; - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveCompletion = checkResolve; - - // Fallback polling in case WebSocket is blocked (403) - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - console.log('Finished via Polling'); - checkResolve(); + 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…'; } - } catch (err) { console.error('Polling error:', err); } - }, 2000); + } catch (err) { console.error('Poll error:', err); } + }, 1500); }); } @@ -311,7 +257,7 @@ // Only intercept generate actions const submitter = e.submitter; if (!submitter || submitter.value !== 'preview') { - return; + return; } e.preventDefault(); @@ -319,21 +265,19 @@ currentAction = submitter.value; const formData = new FormData(form); formData.append('action', currentAction); - formData.append('client_id', clientId); // UI Reset progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = 'Starting...'; + 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, - headers: { - 'X-Requested-With': 'XMLHttpRequest' - } + headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await response.json(); @@ -344,64 +288,29 @@ return; } - currentPromptId = data.prompt_id; - progressLabel.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - - // Wait for completion (WebSocket or Polling) - await waitForCompletion(currentPromptId); - - // Finalize - finalizeGeneration(currentPromptId, currentAction); - currentPromptId = null; - - } catch (err) { - console.error(err); - alert('Request failed'); - progressContainer.classList.add('d-none'); - } - }); - - async function finalizeGeneration(promptId, action) { - progressLabel.textContent = 'Saving image...'; - const url = `/character/{{ character.slug }}/finalize_generation/${promptId}`; - const formData = new FormData(); - formData.append('action', 'preview'); // Always save as preview - - try { - const response = await fetch(url, { - method: 'POST', - body: formData - }); - const data = await response.json(); - - if (data.success) { - // Update preview image - previewImg.src = data.image_url; + 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'); - - // Enable the replace cover button if it exists const replaceBtn = document.getElementById('replace-cover-btn'); - if (replaceBtn) { - replaceBtn.disabled = false; - // Check if there's a form to update - const form = replaceBtn.closest('form'); - if (form) { - form.action = `/character/{{ character.slug }}/replace_cover_from_preview`; - } - } - } else { - alert('Save failed: ' + data.error); + if (replaceBtn) replaceBtn.disabled = false; } + } catch (err) { console.error(err); - alert('Finalize request failed'); + alert('Generation failed: ' + err.message); } finally { progressContainer.classList.add('d-none'); + progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } - } + }); }); // Image modal function diff --git a/templates/detailers/detail.html b/templates/detailers/detail.html index a564479..e8826ac 100644 --- a/templates/detailers/detail.html +++ b/templates/detailers/detail.html @@ -296,171 +296,59 @@ } }); - // Generate a unique client ID - const clientId = 'detailer_detail_' + Math.random().toString(36).substring(2, 15); - - // ComfyUI WebSocket - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding Image", - "9": "Saving Image" - }; - - let currentPromptId = null; + let currentJobId = null; let currentAction = null; - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'status') { - if (!currentPromptId) { - const queueRemaining = msg.data.status.exec_info.queue_remaining; - if (queueRemaining > 0) { - progressLabel.textContent = `Queue position: ${queueRemaining}`; - } - } - } - else if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - - const nodeId = msg.data.node; - if (nodeId === null) { - // Execution finished via WebSocket - console.log('Finished via WebSocket'); - if (resolveCompletion) resolveCompletion(); - } else { - const nodeName = nodeNames[nodeId] || `Processing...`; - progressLabel.textContent = nodeName; - } - } - }); - - let resolveCompletion = null; - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveCompletion = checkResolve; - - // Fallback polling in case WebSocket is blocked (403) - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - console.log('Finished via Polling'); - checkResolve(); - } - } catch (err) { console.error('Polling error:', err); } - }, 2000); + 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…'; + } catch (err) { console.error('Poll error:', err); } + }, 1500); }); } form.addEventListener('submit', async (e) => { 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('client_id', clientId); - progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = 'Starting...'; - + 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, - headers: { 'X-Requested-With': 'XMLHttpRequest' } + 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; - } - - currentPromptId = data.prompt_id; - progressLabel.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - - await waitForCompletion(currentPromptId); - finalizeGeneration(currentPromptId, currentAction); - currentPromptId = null; - - } catch (err) { - console.error(err); - alert('Request failed'); - progressContainer.classList.add('d-none'); - } - }); - - async function finalizeGeneration(promptId, action) { - progressLabel.textContent = 'Saving image...'; - const url = `/detailer/{{ detailer.slug }}/finalize_generation/${promptId}`; - const formData = new FormData(); - formData.append('action', 'preview'); // Always save as preview - - try { - const response = await fetch(url, { method: 'POST', body: formData }); - const data = await response.json(); - - if (data.success) { - previewImg.src = data.image_url; + 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 form = replaceBtn.closest('form'); - if (form) { - form.action = `/detailer/{{ detailer.slug }}/replace_cover_from_preview`; - } - } - } else { - alert('Save failed: ' + data.error); + if (replaceBtn) replaceBtn.disabled = false; } - } catch (err) { - console.error(err); - alert('Finalize request failed'); - } finally { - progressContainer.classList.add('d-none'); - } - } + } 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 %} ]; - const finalizeBaseUrl = '/detailer/{{ detailer.slug }}/finalize_generation'; let stopBatch = false; const generateAllBtn = document.getElementById('generate-all-btn'); @@ -496,13 +384,11 @@ 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++) { 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)); @@ -511,42 +397,27 @@ fd.append('extra_positive', document.getElementById('extra_positive').value); fd.append('extra_negative', document.getElementById('extra_negative').value); fd.append('action', 'preview'); - fd.append('client_id', clientId); - try { progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = `${char.name}: Starting...`; - + progressBar.style.width = '100%'; progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + progressLabel.textContent = `${char.name}: Queuing…`; const resp = await fetch(genForm.getAttribute('action'), { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - - currentPromptId = data.prompt_id; - await waitForCompletion(currentPromptId); - - progressLabel.textContent = 'Saving image...'; - const finalFD = new FormData(); - finalFD.append('action', 'preview'); - const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); - const finalData = await finalResp.json(); - if (finalData.success) { - addToPreviewGallery(finalData.image_url, char.name); - previewImg.src = finalData.image_url; + 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'); } - currentPromptId = null; - } catch (err) { - console.error(`Failed for ${char.name}:`, err); - currentPromptId = null; - } finally { - progressContainer.classList.add('d-none'); - } + } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; } + finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } } - batchBar.style.width = '100%'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; generateAllBtn.disabled = false; diff --git a/templates/detailers/index.html b/templates/detailers/index.html index 91341b0..685c0c4 100644 --- a/templates/detailers/index.html +++ b/templates/detailers/index.html @@ -104,72 +104,18 @@ const detailerNameText = document.getElementById('current-detailer-name'); const stepProgressText = document.getElementById('current-step-progress'); - const clientId = 'detailers_batch_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding", - "9": "Saving" - }; - - let currentPromptId = null; - let resolveGeneration = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - stepProgressText.textContent = `${percent}%`; - taskProgressBar.style.width = `${percent}%`; - taskProgressBar.textContent = `${percent}%`; - taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveGeneration) resolveGeneration(); - } else { - nodeStatus.textContent = nodeNames[nodeId] || `Processing...`; - stepProgressText.textContent = ""; - if (nodeId !== "3") { - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - } - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveGeneration = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - checkResolve(); - } + 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') nodeStatus.textContent = 'Generating…'; + else nodeStatus.textContent = 'Queued…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -186,67 +132,66 @@ batchBtn.disabled = true; 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 Detailers: ${completed + 1} / ${missing.length}`; - detailerNameText.textContent = `Current: ${item.name}`; - nodeStatus.textContent = "Queuing..."; - - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = 'Queued'; - 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(`/detailer/${item.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ - 'action': 'replace', - 'client_id': clientId, - 'character_slug': '__random__' - }), + body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentPromptId = genData.prompt_id; + if (genData.job_id) jobs.push({ item, jobId: genData.job_id }); + } catch (err) { + console.error(`Failed to queue ${item.name}:`, err); + } + } - await waitForCompletion(currentPromptId); + // 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`; - const finResp = await fetch(`/detailer/${item.slug}/finalize_generation/${currentPromptId}`, { - method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }) - }); - const finData = await finResp.json(); - - if (finData.success) { + 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}`); - if (img) { - img.src = finData.image_url; - img.classList.remove('d-none'); - } + 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 ${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 Detailer Generation Complete!"; - detailerNameText.textContent = ""; - nodeStatus.textContent = "Done"; - stepProgressText.textContent = ""; + statusText.textContent = 'Batch Detailer Generation Complete!'; + detailerNameText.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/generator.html b/templates/generator.html index 23f11dd..5cee13a 100644 --- a/templates/generator.html +++ b/templates/generator.html @@ -310,73 +310,24 @@ const placeholder = document.getElementById('placeholder-text'); const resultFooter = document.getElementById('result-footer'); - const clientId = 'generator_view_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); + let currentJobId = null; + let stopRequested = false; - const nodeNames = { - "3": "Sampling", "4": "Loading Models", "8": "Decoding Image", "9": "Saving Image", - "11": "Face Detailing", "13": "Hand Detailing", - "16": "Character LoRA", "17": "Outfit LoRA", "18": "Action LoRA", "19": "Style/Detailer LoRA" - }; - - let currentPromptId = null; - let resolveCompletion = null; - let stopRequested = false; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - if (msg.type === 'status') { - if (!currentPromptId) { - const q = msg.data.status.exec_info.queue_remaining; - if (q > 0) progressLbl.textContent = `Queue position: ${q}`; - } - } else if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const pct = Math.round((msg.data.value / msg.data.max) * 100); - progressBar.style.width = `${pct}%`; - progressBar.textContent = `${pct}%`; - } else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - if (msg.data.node === null) { - if (resolveCompletion) resolveCompletion(); - } else { - progressLbl.textContent = nodeNames[msg.data.node] || 'Processing...'; - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const done = () => { clearInterval(poll); resolve(); }; - resolveCompletion = done; + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { const poll = setInterval(async () => { try { - const r = await fetch(`/check_status/${promptId}`); - if ((await r.json()).status === 'finished') done(); - } catch (_) {} - }, 2000); + 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') progressLbl.textContent = 'Generating…'; + else progressLbl.textContent = 'Queued…'; + } catch (err) {} + }, 1500); }); } - async function finalizeGeneration(slug, promptId) { - progressLbl.textContent = 'Saving image...'; - try { - const r = await fetch(`/generator/finalize/${slug}/${promptId}`, { method: 'POST' }); - const data = await r.json(); - if (data.success) { - resultImg.src = data.image_url; - resultImg.parentElement.classList.remove('d-none'); - if (placeholder) placeholder.classList.add('d-none'); - resultFooter.classList.remove('d-none'); - } else { - alert('Save failed: ' + data.error); - } - } catch (err) { - console.error(err); - alert('Finalize request failed'); - } - } - function setGeneratingState(active) { generateBtn.disabled = active; endlessBtn.disabled = active; @@ -388,12 +339,11 @@ if (document.getElementById('lucky-dip').checked) applyLuckyDip(); progressCont.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; + progressBar.style.width = '100%'; progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); progressLbl.textContent = label; const fd = new FormData(form); - fd.append('client_id', clientId); const resp = await fetch(form.action, { method: 'POST', body: fd, @@ -402,15 +352,19 @@ const data = await resp.json(); if (data.error) throw new Error(data.error); - currentPromptId = data.prompt_id; - progressLbl.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + currentJobId = data.job_id; + progressLbl.textContent = 'Queued…'; - await waitForCompletion(currentPromptId); - await finalizeGeneration(document.getElementById('character').value, currentPromptId); - currentPromptId = null; + const jobResult = await waitForJob(currentJobId); + currentJobId = null; + + if (jobResult.result && jobResult.result.image_url) { + resultImg.src = jobResult.result.image_url; + resultImg.parentElement.classList.remove('d-none'); + if (placeholder) placeholder.classList.add('d-none'); + resultFooter.classList.remove('d-none'); + } + progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } async function runLoop(endless) { diff --git a/templates/index.html b/templates/index.html index ad58c16..6ab51f3 100644 --- a/templates/index.html +++ b/templates/index.html @@ -103,72 +103,18 @@ const charNameText = document.getElementById('current-char-name'); const stepProgressText = document.getElementById('current-step-progress'); - const clientId = 'gallery_batch_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding", - "9": "Saving" - }; - - let currentPromptId = null; - let resolveGeneration = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - stepProgressText.textContent = `${percent}%`; - taskProgressBar.style.width = `${percent}%`; - taskProgressBar.textContent = `${percent}%`; - taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveGeneration) resolveGeneration(); - } else { - nodeStatus.textContent = nodeNames[nodeId] || `Processing...`; - stepProgressText.textContent = ""; - // Reset task bar for new node if it's not sampling - if (nodeId !== "3") { - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - } - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveGeneration = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - checkResolve(); - } + 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') nodeStatus.textContent = 'Generating…'; + else nodeStatus.textContent = 'Queued…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -185,63 +131,66 @@ batchBtn.disabled = true; regenAllBtn.disabled = true; container.classList.remove('d-none'); - - let completed = 0; - for (const char 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}`; - charNameText.textContent = `Current: ${char.name}`; - nodeStatus.textContent = "Queuing..."; - - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = 'Queued'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + // Phase 1: Queue all jobs upfront so the page can be navigated away from + progressBar.style.width = '100%'; + progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + nodeStatus.textContent = 'Queuing…'; + + const jobs = []; + for (const char of missing) { + statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}…`; try { const genResp = await fetch(`/character/${char.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ 'action': 'replace', 'client_id': clientId }), + body: new URLSearchParams({ action: 'replace' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentPromptId = genData.prompt_id; + if (genData.job_id) jobs.push({ item: char, jobId: genData.job_id }); + } catch (err) { + console.error(`Failed to queue ${char.name}:`, err); + } + } - await waitForCompletion(currentPromptId); + // Phase 2: Poll all jobs concurrently; update UI as each finishes + progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); + progressBar.style.width = '0%'; + progressBar.textContent = '0%'; + statusText.textContent = `0 / ${jobs.length} done`; - const finResp = await fetch(`/character/${char.slug}/finalize_generation/${currentPromptId}`, { - method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }) - }); - const finData = await finResp.json(); - - if (finData.success) { - const img = document.getElementById(`img-${char.slug}`); - const noImgSpan = document.getElementById(`no-img-${char.slug}`); - if (img) { - img.src = finData.image_url; - img.classList.remove('d-none'); - } + 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}`); + 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 ${char.name}:`, err); + 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 Complete!"; - charNameText.textContent = ""; - nodeStatus.textContent = "Done"; - stepProgressText.textContent = ""; + statusText.textContent = 'Batch Complete!'; + charNameText.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/layout.html b/templates/layout.html index 81db171..8f7f03f 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -29,6 +29,12 @@ Gallery Settings
+ + +
@@ -94,6 +100,31 @@ + + + + {% block scripts %}{% endblock %} diff --git a/templates/looks/detail.html b/templates/looks/detail.html index e2674ff..d39db69 100644 --- a/templates/looks/detail.html +++ b/templates/looks/detail.html @@ -239,45 +239,20 @@ const previewCard = document.getElementById('preview-card'); const previewImg = document.getElementById('preview-img'); - const clientId = 'look_detail_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); + let currentJobId = null; - const nodeNames = { - "3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", - "4": "Loading Models", "16": "Look LoRA", "17": "Outfit LoRA", - "18": "Action LoRA", "19": "Style/Detailer LoRA", - "8": "Decoding Image", "9": "Saving Image" - }; - - let currentPromptId = null; - let resolveCompletion = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - if (msg.type === 'progress' && msg.data.prompt_id === currentPromptId) { - const percent = Math.round((msg.data.value / msg.data.max) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - } else if (msg.type === 'executing' && msg.data.prompt_id === currentPromptId) { - if (msg.data.node === null) { - if (resolveCompletion) resolveCompletion(); - } else { - progressLabel.textContent = nodeNames[msg.data.node] || 'Processing...'; - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { clearInterval(pollInterval); resolve(); }; - resolveCompletion = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') checkResolve(); - } catch (err) { console.error('Polling error:', err); } - }, 2000); + 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…'; + } catch (err) { console.error('Poll error:', err); } + }, 1500); }); } @@ -285,63 +260,30 @@ const submitter = e.submitter; if (!submitter || submitter.value !== 'preview') return; e.preventDefault(); - const formData = new FormData(form); formData.append('action', 'preview'); - formData.append('client_id', clientId); - progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = 'Starting...'; - + 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, - headers: { 'X-Requested-With': 'XMLHttpRequest' } + 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; } - - currentPromptId = data.prompt_id; - progressLabel.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - - await waitForCompletion(currentPromptId); - finalizeGeneration(currentPromptId); - currentPromptId = null; - } catch (err) { - console.error(err); - alert('Request failed'); - progressContainer.classList.add('d-none'); - } - }); - - async function finalizeGeneration(promptId) { - progressLabel.textContent = 'Saving image...'; - const url = `/look/{{ look.slug }}/finalize_generation/${promptId}`; - try { - const response = await fetch(url, { - method: 'POST', body: new FormData() - }); - const data = await response.json(); - if (data.success) { - previewImg.src = data.image_url; + 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; - } else { - alert('Save failed: ' + data.error); } - } catch (err) { - console.error(err); - alert('Finalize request failed'); - } finally { - progressContainer.classList.add('d-none'); - } - } + } catch (err) { console.error(err); alert('Generation failed: ' + err.message); } + finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } + }); }); function showImage(src) { diff --git a/templates/looks/index.html b/templates/looks/index.html index 4bd9a72..f7cbd99 100644 --- a/templates/looks/index.html +++ b/templates/looks/index.html @@ -105,56 +105,20 @@ const itemNameText = document.getElementById('current-item-name'); const stepProgressText = document.getElementById('current-step-progress'); - const clientId = 'looks_batch_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); + let currentJobId = null; - const nodeNames = { - "3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", - "4": "Loading Models", "16": "Look LoRA", "17": "Outfit LoRA", - "18": "Action LoRA", "19": "Style/Detailer LoRA", - "8": "Decoding", "9": "Saving" - }; - - let currentPromptId = null; - let resolveGeneration = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const percent = Math.round((msg.data.value / msg.data.max) * 100); - stepProgressText.textContent = `${percent}%`; - taskProgressBar.style.width = `${percent}%`; - taskProgressBar.textContent = `${percent}%`; - taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveGeneration) resolveGeneration(); - } else { - nodeStatus.textContent = nodeNames[nodeId] || 'Processing...'; - stepProgressText.textContent = ""; - if (nodeId !== "3") { - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - } - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { clearInterval(pollInterval); resolve(); }; - resolveGeneration = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') checkResolve(); + 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') nodeStatus.textContent = 'Generating…'; + else nodeStatus.textContent = 'Queued…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -179,40 +143,32 @@ progressBar.textContent = `${percent}%`; statusText.textContent = `Batch Generating Looks: ${completed + 1} / ${missing.length}`; itemNameText.textContent = `Current: ${item.name}`; - nodeStatus.textContent = "Queuing..."; + nodeStatus.textContent = "Queuing…"; taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = 'Queued'; + taskProgressBar.textContent = ''; taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); try { - // Looks are self-contained — no character_slug passed const genResp = await fetch(`/look/${item.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ - 'action': 'replace', - 'client_id': clientId - }), + body: new URLSearchParams({ 'action': 'replace' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentPromptId = genData.prompt_id; + currentJobId = genData.job_id; - await waitForCompletion(currentPromptId); + const jobResult = await waitForJob(currentJobId); + currentJobId = null; - const finResp = await fetch(`/look/${item.slug}/finalize_generation/${currentPromptId}`, { - method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }) - }); - const finData = await finResp.json(); - - if (finData.success) { + if (jobResult.result && jobResult.result.image_url) { const img = document.getElementById(`img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`); - if (img) { img.src = finData.image_url; img.classList.remove('d-none'); } + 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 ${item.name}:`, err); + currentJobId = null; } completed++; } diff --git a/templates/outfits/detail.html b/templates/outfits/detail.html index 793e215..5265b53 100644 --- a/templates/outfits/detail.html +++ b/templates/outfits/detail.html @@ -272,82 +272,28 @@ const previewCard = document.getElementById('preview-card'); const previewImg = document.getElementById('preview-img'); - // Generate a unique client ID - const clientId = 'outfit_detail_' + Math.random().toString(36).substring(2, 15); - - // ComfyUI WebSocket - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding Image", - "9": "Saving Image" - }; - - let currentPromptId = null; + let currentJobId = null; let currentAction = null; - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'status') { - if (!currentPromptId) { - const queueRemaining = msg.data.status.exec_info.queue_remaining; - if (queueRemaining > 0) { - progressLabel.textContent = `Queue position: ${queueRemaining}`; - } - } - } - else if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - - const nodeId = msg.data.node; - if (nodeId === null) { - // Execution finished via WebSocket - console.log('Finished via WebSocket'); - if (resolveCompletion) resolveCompletion(); - } else { - const nodeName = nodeNames[nodeId] || `Processing...`; - progressLabel.textContent = nodeName; - } - } - }); - - let resolveCompletion = null; - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveCompletion = checkResolve; - - // Fallback polling in case WebSocket is blocked (403) - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - console.log('Finished via Polling'); - checkResolve(); + 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…'; } - } catch (err) { console.error('Polling error:', err); } - }, 2000); + } catch (err) { console.error('Poll error:', err); } + }, 1500); }); } @@ -355,7 +301,7 @@ // Only intercept generate actions const submitter = e.submitter; if (!submitter || submitter.value !== 'preview') { - return; + return; } e.preventDefault(); @@ -363,21 +309,19 @@ currentAction = submitter.value; const formData = new FormData(form); formData.append('action', currentAction); - formData.append('client_id', clientId); // UI Reset progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = 'Starting...'; + 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, - headers: { - 'X-Requested-With': 'XMLHttpRequest' - } + headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await response.json(); @@ -388,71 +332,33 @@ return; } - currentPromptId = data.prompt_id; - progressLabel.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - - // Wait for completion (WebSocket or Polling) - await waitForCompletion(currentPromptId); - - // Finalize - finalizeGeneration(currentPromptId, currentAction); - currentPromptId = null; - - } catch (err) { - console.error(err); - alert('Request failed'); - progressContainer.classList.add('d-none'); - } - }); - - async function finalizeGeneration(promptId, action) { - progressLabel.textContent = 'Saving image...'; - const url = `/outfit/{{ outfit.slug }}/finalize_generation/${promptId}`; - const formData = new FormData(); - formData.append('action', 'preview'); // Always save as preview - - try { - const response = await fetch(url, { - method: 'POST', - body: formData - }); - const data = await response.json(); - - if (data.success) { - // Update preview image - previewImg.src = data.image_url; + currentJobId = data.job_id; + progressLabel.textContent = 'Queued…'; + + 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'); - - // Enable the replace cover button if it exists const replaceBtn = document.getElementById('replace-cover-btn'); - if (replaceBtn) { - replaceBtn.disabled = false; - // Check if there's a form to update - const form = replaceBtn.closest('form'); - if (form) { - form.action = `/outfit/{{ outfit.slug }}/replace_cover_from_preview`; - } - } - } else { - alert('Save failed: ' + data.error); + if (replaceBtn) replaceBtn.disabled = false; } + } catch (err) { console.error(err); - alert('Finalize request failed'); + 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 %} ]; - const finalizeBaseUrl = '/outfit/{{ outfit.slug }}/finalize_generation'; let stopBatch = false; const generateAllBtn = document.getElementById('generate-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn'); @@ -497,35 +403,31 @@ 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'); - fd.append('client_id', clientId); try { progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = `${char.name}: Starting...`; + progressBar.style.width = '100%'; + progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + progressLabel.textContent = `${char.name}: Queuing…`; const resp = await fetch(genForm.getAttribute('action'), { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - currentPromptId = data.prompt_id; - await waitForCompletion(currentPromptId); - progressLabel.textContent = 'Saving image...'; - const finalFD = new FormData(); - finalFD.append('action', 'preview'); - const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); - const finalData = await finalResp.json(); - if (finalData.success) { - addToPreviewGallery(finalData.image_url, char.name); - previewImg.src = finalData.image_url; + 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'); } - currentPromptId = null; } catch (err) { console.error(`Failed for ${char.name}:`, err); - currentPromptId = null; + currentJobId = null; } finally { progressContainer.classList.add('d-none'); + progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } } batchBar.style.width = '100%'; diff --git a/templates/outfits/index.html b/templates/outfits/index.html index 18df036..1b92f82 100644 --- a/templates/outfits/index.html +++ b/templates/outfits/index.html @@ -102,71 +102,18 @@ const itemNameText = document.getElementById('current-item-name'); const stepProgressText = document.getElementById('current-step-progress'); - const clientId = 'outfits_batch_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding", - "9": "Saving" - }; - - let currentPromptId = null; - let resolveGeneration = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - stepProgressText.textContent = `${percent}%`; - taskProgressBar.style.width = `${percent}%`; - taskProgressBar.textContent = `${percent}%`; - taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveGeneration) resolveGeneration(); - } else { - nodeStatus.textContent = nodeNames[nodeId] || `Processing...`; - stepProgressText.textContent = ""; - if (nodeId !== "3") { - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - } - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveGeneration = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - checkResolve(); - } + 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') nodeStatus.textContent = 'Generating…'; + else nodeStatus.textContent = 'Queued…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -183,67 +130,66 @@ batchBtn.disabled = true; 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 Outfits: ${completed + 1} / ${missing.length}`; - itemNameText.textContent = `Current: ${item.name}`; - nodeStatus.textContent = "Queuing..."; - - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = 'Queued'; - 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(`/outfit/${item.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ - 'action': 'replace', - 'client_id': clientId, - 'character_slug': '__random__' - }), + body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentPromptId = genData.prompt_id; + if (genData.job_id) jobs.push({ item, jobId: genData.job_id }); + } catch (err) { + console.error(`Failed to queue ${item.name}:`, err); + } + } - await waitForCompletion(currentPromptId); + // 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`; - const finResp = await fetch(`/outfit/${item.slug}/finalize_generation/${currentPromptId}`, { - method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }) - }); - const finData = await finResp.json(); - - if (finData.success) { + 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}`); - if (img) { - img.src = finData.image_url; - img.classList.remove('d-none'); - } + 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 ${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 Outfit Generation Complete!"; - itemNameText.textContent = ""; - nodeStatus.textContent = "Done"; - stepProgressText.textContent = ""; + statusText.textContent = 'Batch Outfit 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/partials/strengths_gallery.html b/templates/partials/strengths_gallery.html index 0cd8fc8..3e2503a 100644 --- a/templates/partials/strengths_gallery.html +++ b/templates/partials/strengths_gallery.html @@ -101,11 +101,10 @@ const SG_CAT = {{ sg_category | tojson }}; const SG_SLUG = {{ sg_entity.slug | tojson }}; - const SG_WS = {{ COMFYUI_WS_URL | tojson }}; - const SG_CLIENT_ID = 'sg_' + Math.random().toString(36).slice(2, 10); let sgRunning = false; let sgShouldStop = false; + let sgQueuedJobs = []; // track all queued job IDs so stop can cancel them // ---- helpers ---- @@ -241,52 +240,23 @@ sgHighlightBounds(); } - // ---- WebSocket wait ---- + // ---- Job queue wait ---- - function sgWaitForCompletion(promptId) { + function sgWaitForJob(jobId) { return new Promise((resolve, reject) => { - let ws; - try { - ws = new WebSocket(`${SG_WS}?clientId=${SG_CLIENT_ID}`); - } catch (e) { - // Fall back to polling if WS unavailable - sgPollUntilDone(promptId).then(resolve).catch(reject); - return; - } - - const timeout = setTimeout(() => { - ws.close(); - sgPollUntilDone(promptId).then(resolve).catch(reject); - }, 120000); - - ws.onmessage = (event) => { - let msg; - try { msg = JSON.parse(event.data); } catch { return; } - if (msg.type === 'executing' && msg.data && msg.data.prompt_id === promptId) { - if (msg.data.node === null) { - clearTimeout(timeout); - ws.close(); - resolve(); + const poll = setInterval(async () => { + 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')); } - } - }; - - ws.onerror = () => { - clearTimeout(timeout); - sgPollUntilDone(promptId).then(resolve).catch(reject); - }; + } catch (err) { console.error('[Strengths] poll error:', err); } + }, 1500); }); } - async function sgPollUntilDone(promptId) { - for (let i = 0; i < 120; i++) { - await new Promise(r => setTimeout(r, 2000)); - const r = await fetch(`/check_status/${promptId}`); - const d = await r.json(); - if (d.status === 'complete' || d.status === 'finished' || d.done) return; - } - } - // ---- main flow ---- async function sgClearImages() { @@ -310,64 +280,65 @@ const steps = sgBuildSteps(min, max, sgGetInterval()); if (!steps.length) return; - // Clear any previous set before starting a new one await sgClearImages(); sgRunning = true; sgShouldStop = false; + sgQueuedJobs = []; document.getElementById('sg-btn-run').classList.add('d-none'); document.getElementById('sg-btn-stop').classList.remove('d-none'); document.getElementById('sg-progress').classList.remove('d-none'); + const charSelect = document.getElementById('character_select'); + const charSlug = charSelect ? charSelect.value : ''; + + // Phase 1: Queue all steps upfront so generation continues even if the page is navigated away + document.getElementById('sg-progress-bar').style.width = '100%'; + document.getElementById('sg-progress-bar').classList.add('progress-bar-striped', 'progress-bar-animated'); for (let i = 0; i < steps.length; i++) { if (sgShouldStop) break; - const sv = steps[i]; - const pct = Math.round(((i) / steps.length) * 100); - document.getElementById('sg-progress-bar').style.width = pct + '%'; document.getElementById('sg-progress-label').textContent = - `${i} / ${steps.length} \u2014 weight: ${sv}`; - + `Queuing ${i + 1} / ${steps.length} \u2014 weight: ${sv}`; try { - // Queue one generation - // Pick up the character currently selected on this detail page (if any) - const charSelect = document.getElementById('character_select'); - const charSlug = charSelect ? charSelect.value : ''; - const formData = new URLSearchParams({ - strength_value: sv, - seed: seed, - client_id: SG_CLIENT_ID, - character_slug: charSlug, - }); + const formData = new URLSearchParams({ strength_value: sv, seed, character_slug: charSlug }); const queueResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/generate`, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, body: formData, }); const queueData = await queueResp.json(); - if (!queueData.prompt_id) throw new Error('No prompt_id returned'); - - await sgWaitForCompletion(queueData.prompt_id); - - // Finalize - const finData = new URLSearchParams({ strength_value: sv, seed: seed }); - const finResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/finalize/${queueData.prompt_id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: finData, - }); - const finJson = await finResp.json(); - if (finJson.success && finJson.image_url) { - sgAddImage(finJson.image_url, sv); - } + if (!queueData.job_id) throw new Error('No job_id returned'); + sgQueuedJobs.push({ jobId: queueData.job_id, sv }); } catch (err) { - console.error('[Strengths] step error:', sv, err); + console.error('[Strengths] queue error:', sv, err); } } + // Phase 2: Poll all jobs concurrently; show results as each finishes + document.getElementById('sg-progress-bar').classList.remove('progress-bar-striped', 'progress-bar-animated'); + document.getElementById('sg-progress-bar').style.width = '0%'; + + let completed = 0; + await Promise.all(sgQueuedJobs.map(async ({ jobId, sv }) => { + try { + const jobResult = await sgWaitForJob(jobId); + if (jobResult.result && jobResult.result.image_url) { + sgAddImage(jobResult.result.image_url, sv); + } + } catch (err) { + console.error('[Strengths] job error:', sv, err); + } + completed++; + const pct = Math.round((completed / sgQueuedJobs.length) * 100); + document.getElementById('sg-progress-bar').style.width = pct + '%'; + document.getElementById('sg-progress-label').textContent = + `${completed} / ${sgQueuedJobs.length} done`; + })); + document.getElementById('sg-progress-bar').style.width = '100%'; document.getElementById('sg-progress-label').textContent = - `Done \u2014 ${steps.length} images generated`; + `Done \u2014 ${sgQueuedJobs.length} images generated`; setTimeout(() => document.getElementById('sg-progress').classList.add('d-none'), 3000); document.getElementById('sg-btn-stop').classList.add('d-none'); @@ -377,6 +348,10 @@ function sgStop() { sgShouldStop = true; + // Cancel any pending (not yet processing) queued jobs + sgQueuedJobs.forEach(({ jobId }) => { + fetch(`/api/queue/${jobId}/remove`, { method: 'POST' }).catch(() => {}); + }); document.getElementById('sg-btn-stop').classList.add('d-none'); document.getElementById('sg-btn-run').classList.remove('d-none'); } diff --git a/templates/scenes/detail.html b/templates/scenes/detail.html index 1a643e6..2c94cf0 100644 --- a/templates/scenes/detail.html +++ b/templates/scenes/detail.html @@ -292,173 +292,57 @@ } }); - // Generate a unique client ID - const clientId = 'scene_detail_' + Math.random().toString(36).substring(2, 15); + let currentJobId = null; - // ComfyUI WebSocket - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding Image", - "9": "Saving Image" - }; - - let currentPromptId = null; - let resolveCompletion = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'status') { - if (!currentPromptId) { - const queueRemaining = msg.data.status.exec_info.queue_remaining; - if (queueRemaining > 0) { - progressLabel.textContent = `Queue position: ${queueRemaining}`; - } - } - } - else if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - - const nodeId = msg.data.node; - if (nodeId === null) { - // Execution finished via WebSocket - console.log('Finished via WebSocket'); - if (resolveCompletion) resolveCompletion(); - } else { - const nodeName = nodeNames[nodeId] || `Processing...`; - progressLabel.textContent = nodeName; - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveCompletion = checkResolve; - - // Fallback polling in case WebSocket is blocked (403) - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - console.log('Finished via Polling'); - checkResolve(); - } - } catch (err) { console.error('Polling error:', err); } - }, 2000); + 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…'; + } catch (err) { console.error('Poll error:', err); } + }, 1500); }); } form.addEventListener('submit', async (e) => { const submitter = e.submitter; if (!submitter || submitter.value !== 'preview') return; - e.preventDefault(); - const formData = new FormData(form); formData.append('action', 'preview'); - formData.append('client_id', clientId); - progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = 'Starting...'; - + 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, - headers: { 'X-Requested-With': 'XMLHttpRequest' } + 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; - } - - currentPromptId = data.prompt_id; - progressLabel.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - - // Wait for completion (WebSocket or Polling) - await waitForCompletion(currentPromptId); - - // Finalize - finalizeGeneration(currentPromptId); - currentPromptId = null; - - } catch (err) { - console.error(err); - alert('Request failed'); - progressContainer.classList.add('d-none'); - } - }); - - async function finalizeGeneration(promptId) { - progressLabel.textContent = 'Saving image...'; - const url = `/scene/{{ scene.slug }}/finalize_generation/${promptId}`; - const formData = new FormData(); - formData.append('action', 'preview'); - - try { - const response = await fetch(url, { method: 'POST', body: formData }); - const data = await response.json(); - - if (data.success) { - // Update preview image - previewImg.src = data.image_url; + 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 form = replaceBtn.closest('form'); - if (form) { - form.action = `/scene/{{ scene.slug }}/replace_cover_from_preview`; - } - } - } else { - alert('Save failed: ' + data.error); + if (replaceBtn) replaceBtn.disabled = false; } - } catch (err) { - console.error(err); - alert('Finalize request failed'); - } finally { - progressContainer.classList.add('d-none'); - } - } + } 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 %} ]; - const finalizeBaseUrl = '/scene/{{ scene.slug }}/finalize_generation'; let stopBatch = false; const generateAllBtn = document.getElementById('generate-all-btn'); @@ -494,54 +378,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++) { 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'); - fd.append('client_id', clientId); - try { progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = `${char.name}: Starting...`; - + progressBar.style.width = '100%'; progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + progressLabel.textContent = `${char.name}: Queuing…`; const resp = await fetch(genForm.getAttribute('action'), { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - - currentPromptId = data.prompt_id; - await waitForCompletion(currentPromptId); - - progressLabel.textContent = 'Saving image...'; - const finalFD = new FormData(); - finalFD.append('action', 'preview'); - const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); - const finalData = await finalResp.json(); - if (finalData.success) { - addToPreviewGallery(finalData.image_url, char.name); - previewImg.src = finalData.image_url; + 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'); } - currentPromptId = null; - } catch (err) { - console.error(`Failed for ${char.name}:`, err); - currentPromptId = null; - } finally { - progressContainer.classList.add('d-none'); - } + } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; } + finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } } - batchBar.style.width = '100%'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; generateAllBtn.disabled = false; diff --git a/templates/scenes/index.html b/templates/scenes/index.html index b688baa..09b6341 100644 --- a/templates/scenes/index.html +++ b/templates/scenes/index.html @@ -102,71 +102,18 @@ const sceneNameText = document.getElementById('current-scene-name'); const stepProgressText = document.getElementById('current-step-progress'); - const clientId = 'scenes_batch_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding", - "9": "Saving" - }; - - let currentPromptId = null; - let resolveGeneration = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - stepProgressText.textContent = `${percent}%`; - taskProgressBar.style.width = `${percent}%`; - taskProgressBar.textContent = `${percent}%`; - taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveGeneration) resolveGeneration(); - } else { - nodeStatus.textContent = nodeNames[nodeId] || `Processing...`; - stepProgressText.textContent = ""; - if (nodeId !== "3") { - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - } - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveGeneration = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - checkResolve(); - } + 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') nodeStatus.textContent = 'Generating…'; + else nodeStatus.textContent = 'Queued…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -183,66 +130,65 @@ batchBtn.disabled = true; regenAllBtn.disabled = true; container.classList.remove('d-none'); - - let completed = 0; - for (const scene of missing) { - const percent = Math.round((completed / missing.length) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - statusText.textContent = `Batch Generating Scenes: ${completed + 1} / ${missing.length}`; - sceneNameText.textContent = `Current: ${scene.name}`; - nodeStatus.textContent = "Queuing..."; - - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = 'Queued'; - 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 scene of missing) { + statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}…`; try { const genResp = await fetch(`/scene/${scene.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ - 'action': 'replace', - 'client_id': clientId, - 'character_slug': '__random__' - }), + body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentPromptId = genData.prompt_id; + if (genData.job_id) jobs.push({ item: scene, jobId: genData.job_id }); + } catch (err) { + console.error(`Failed to queue ${scene.name}:`, err); + } + } - await waitForCompletion(currentPromptId); + // 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`; - const finResp = await fetch(`/scene/${scene.slug}/finalize_generation/${currentPromptId}`, { - method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }) - }); - const finData = await finResp.json(); - - if (finData.success) { - const img = document.getElementById(`img-${scene.slug}`); - const noImgSpan = document.getElementById(`no-img-${scene.slug}`); - if (img) { - img.src = finData.image_url; - img.classList.remove('d-none'); - } + 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}`); + 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 ${scene.name}:`, err); + 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 Scene Generation Complete!"; - sceneNameText.textContent = ""; - nodeStatus.textContent = "Done"; - stepProgressText.textContent = ""; + statusText.textContent = 'Batch Scene Generation Complete!'; + sceneNameText.textContent = ''; + nodeStatus.textContent = 'Done'; + stepProgressText.textContent = ''; taskProgressBar.style.width = '0%'; taskProgressBar.textContent = ''; batchBtn.disabled = false; - setTimeout(() => { container.classList.add('d-none'); }, 5000); + setTimeout(() => container.classList.add('d-none'), 5000); } batchBtn.addEventListener('click', async () => { diff --git a/templates/styles/detail.html b/templates/styles/detail.html index ecf3b4b..5b4143a 100644 --- a/templates/styles/detail.html +++ b/templates/styles/detail.html @@ -285,170 +285,59 @@ } }); - // Generate a unique client ID - const clientId = 'style_detail_' + Math.random().toString(36).substring(2, 15); - - // ComfyUI WebSocket - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding Image", - "9": "Saving Image" - }; - - let currentPromptId = null; + let currentJobId = null; let currentAction = null; - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'status') { - if (!currentPromptId) { - const queueRemaining = msg.data.status.exec_info.queue_remaining; - if (queueRemaining > 0) { - progressLabel.textContent = `Queue position: ${queueRemaining}`; - } - } - } - else if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - - const nodeId = msg.data.node; - if (nodeId === null) { - // Execution finished via WebSocket - console.log('Finished via WebSocket'); - if (resolveCompletion) resolveCompletion(); - } else { - const nodeName = nodeNames[nodeId] || `Processing...`; - progressLabel.textContent = nodeName; - } - } - }); - - let resolveCompletion = null; - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveCompletion = checkResolve; - - // Fallback polling in case WebSocket is blocked (403) - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - checkResolve(); - } - } catch (err) { console.error('Polling error:', err); } - }, 2000); + 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…'; + } catch (err) { console.error('Poll error:', err); } + }, 1500); }); } form.addEventListener('submit', async (e) => { 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('client_id', clientId); - progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = 'Starting...'; - + 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, - headers: { 'X-Requested-With': 'XMLHttpRequest' } + 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; - } - - currentPromptId = data.prompt_id; - progressLabel.textContent = 'Queued...'; - progressBar.style.width = '100%'; - progressBar.textContent = 'Queued'; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - - await waitForCompletion(currentPromptId); - finalizeGeneration(currentPromptId, currentAction); - currentPromptId = null; - - } catch (err) { - console.error(err); - alert('Request failed'); - progressContainer.classList.add('d-none'); - } - }); - - async function finalizeGeneration(promptId, action) { - progressLabel.textContent = 'Saving image...'; - const url = `/style/{{ style.slug }}/finalize_generation/${promptId}`; - const formData = new FormData(); - formData.append('action', 'preview'); // Always save as preview - - try { - const response = await fetch(url, { method: 'POST', body: formData }); - const data = await response.json(); - - if (data.success) { - previewImg.src = data.image_url; + 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 form = replaceBtn.closest('form'); - if (form) { - form.action = `/style/{{ style.slug }}/replace_cover_from_preview`; - } - } - } else { - alert('Save failed: ' + data.error); + if (replaceBtn) replaceBtn.disabled = false; } - } catch (err) { - console.error(err); - alert('Finalize request failed'); - } finally { - progressContainer.classList.add('d-none'); - } - } + } 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 %} ]; - const finalizeBaseUrl = '/style/{{ style.slug }}/finalize_generation'; let stopBatch = false; const generateAllBtn = document.getElementById('generate-all-btn'); @@ -484,54 +373,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++) { 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'); - fd.append('client_id', clientId); - try { progressContainer.classList.remove('d-none'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - progressLabel.textContent = `${char.name}: Starting...`; - + progressBar.style.width = '100%'; progressBar.textContent = ''; + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + progressLabel.textContent = `${char.name}: Queuing…`; const resp = await fetch(genForm.getAttribute('action'), { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await resp.json(); if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } - - currentPromptId = data.prompt_id; - await waitForCompletion(currentPromptId); - - progressLabel.textContent = 'Saving image...'; - const finalFD = new FormData(); - finalFD.append('action', 'preview'); - const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); - const finalData = await finalResp.json(); - if (finalData.success) { - addToPreviewGallery(finalData.image_url, char.name); - previewImg.src = finalData.image_url; + 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'); } - currentPromptId = null; - } catch (err) { - console.error(`Failed for ${char.name}:`, err); - currentPromptId = null; - } finally { - progressContainer.classList.add('d-none'); - } + } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; } + finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } } - batchBar.style.width = '100%'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; generateAllBtn.disabled = false; diff --git a/templates/styles/index.html b/templates/styles/index.html index 511c4ed..f5e8fff 100644 --- a/templates/styles/index.html +++ b/templates/styles/index.html @@ -102,71 +102,18 @@ const styleNameText = document.getElementById('current-style-name'); const stepProgressText = document.getElementById('current-step-progress'); - const clientId = 'styles_batch_' + Math.random().toString(36).substring(2, 15); - const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); - - const nodeNames = { - "3": "Sampling", - "11": "Face Detailing", - "13": "Hand Detailing", - "4": "Loading Models", - "16": "Character LoRA", - "17": "Outfit LoRA", - "18": "Action LoRA", - "19": "Style/Detailer LoRA", - "8": "Decoding", - "9": "Saving" - }; - - let currentPromptId = null; - let resolveGeneration = null; - - socket.addEventListener('message', (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'progress') { - if (msg.data.prompt_id !== currentPromptId) return; - const value = msg.data.value; - const max = msg.data.max; - const percent = Math.round((value / max) * 100); - stepProgressText.textContent = `${percent}%`; - taskProgressBar.style.width = `${percent}%`; - taskProgressBar.textContent = `${percent}%`; - taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - } - else if (msg.type === 'executing') { - if (msg.data.prompt_id !== currentPromptId) return; - const nodeId = msg.data.node; - if (nodeId === null) { - if (resolveGeneration) resolveGeneration(); - } else { - nodeStatus.textContent = nodeNames[nodeId] || `Processing...`; - stepProgressText.textContent = ""; - if (nodeId !== "3") { - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...'; - taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - } - } - } - }); - - async function waitForCompletion(promptId) { - return new Promise((resolve) => { - const checkResolve = () => { - clearInterval(pollInterval); - resolve(); - }; - resolveGeneration = checkResolve; - const pollInterval = setInterval(async () => { + async function waitForJob(jobId) { + return new Promise((resolve, reject) => { + const poll = setInterval(async () => { try { - const resp = await fetch(`/check_status/${promptId}`); + const resp = await fetch(`/api/queue/${jobId}/status`); const data = await resp.json(); - if (data.status === 'finished') { - checkResolve(); - } + 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') nodeStatus.textContent = 'Generating…'; + else nodeStatus.textContent = 'Queued…'; } catch (err) {} - }, 2000); + }, 1500); }); } @@ -183,62 +130,61 @@ batchBtn.disabled = true; regenAllBtn.disabled = true; container.classList.remove('d-none'); - - let completed = 0; - for (const style of missing) { - const percent = Math.round((completed / missing.length) * 100); - progressBar.style.width = `${percent}%`; - progressBar.textContent = `${percent}%`; - statusText.textContent = `Batch Generating Styles: ${completed + 1} / ${missing.length}`; - styleNameText.textContent = `Current: ${style.name}`; - nodeStatus.textContent = "Queuing..."; - - taskProgressBar.style.width = '100%'; - taskProgressBar.textContent = 'Queued'; - 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 style of missing) { + statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}…`; try { const genResp = await fetch(`/style/${style.slug}/generate`, { method: 'POST', - body: new URLSearchParams({ - 'action': 'replace', - 'client_id': clientId, - 'character_slug': '__random__' - }), + body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const genData = await genResp.json(); - currentPromptId = genData.prompt_id; + if (genData.job_id) jobs.push({ item: style, jobId: genData.job_id }); + } catch (err) { + console.error(`Failed to queue ${style.name}:`, err); + } + } - await waitForCompletion(currentPromptId); + // 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`; - const finResp = await fetch(`/style/${style.slug}/finalize_generation/${currentPromptId}`, { - method: 'POST', - body: new URLSearchParams({ 'action': 'replace' }) - }); - const finData = await finResp.json(); - - if (finData.success) { - const img = document.getElementById(`img-${style.slug}`); - const noImgSpan = document.getElementById(`no-img-${style.slug}`); - if (img) { - img.src = finData.image_url; - img.classList.remove('d-none'); - } + 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}`); + 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 ${style.name}:`, err); + 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 Style Generation Complete!"; - styleNameText.textContent = ""; - nodeStatus.textContent = "Done"; - stepProgressText.textContent = ""; + statusText.textContent = 'Batch Style Generation Complete!'; + styleNameText.textContent = ''; + nodeStatus.textContent = 'Done'; + stepProgressText.textContent = ''; taskProgressBar.style.width = '0%'; taskProgressBar.textContent = ''; batchBtn.disabled = false;