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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-03 02:32:50 +00:00
parent ae7ba961c1
commit 3c828a170f
21 changed files with 1451 additions and 2146 deletions

591
app.py
View File

@@ -6,6 +6,9 @@ import requests
import random import random
import asyncio import asyncio
import subprocess import subprocess
import threading
import uuid
from collections import deque
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
from flask import Flask, render_template, request, redirect, url_for, flash, session 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) db.init_app(app)
Session(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/<job_id>/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/<job_id>/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/<job_id>/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. # 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_TOOLS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tools')
MCP_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'danbooru-mcp') MCP_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'danbooru-mcp')
@@ -1284,7 +1482,6 @@ def generator():
checkpoint = request.form.get('checkpoint') checkpoint = request.form.get('checkpoint')
custom_positive = request.form.get('positive_prompt', '') custom_positive = request.form.get('positive_prompt', '')
custom_negative = request.form.get('negative_prompt', '') custom_negative = request.form.get('negative_prompt', '')
client_id = request.form.get('client_id')
action_slugs = request.form.getlist('action_slugs') action_slugs = request.form.getlist('action_slugs')
outfit_slugs = request.form.getlist('outfit_slugs') outfit_slugs = request.form.getlist('outfit_slugs')
@@ -1336,44 +1533,33 @@ def generator():
) )
print(f"Queueing generator prompt for {character.character_id}") 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: _char_slug = character.slug
raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") def _finalize(comfy_prompt_id, job):
history = get_history(comfy_prompt_id)
prompt_id = prompt_response['prompt_id'] outputs = history[comfy_prompt_id]['outputs']
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: for node_id in outputs:
if 'images' in outputs[node_id]: if 'images' in outputs[node_id]:
image_info = outputs[node_id]['images'][0] image_info = outputs[node_id]['images'][0]
image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) 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}")
char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{character.slug}")
os.makedirs(char_folder, exist_ok=True) os.makedirs(char_folder, exist_ok=True)
filename = f"gen_{int(time.time())}.png" filename = f"gen_{int(time.time())}.png"
file_path = os.path.join(char_folder, filename) file_path = os.path.join(char_folder, filename)
with open(file_path, 'wb') as f: with open(file_path, 'wb') as f:
f.write(image_data) 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
relative_path = f"characters/{character.slug}/{filename}" label = f"Generator: {character.name}"
return render_template('generator.html', job = _enqueue_job(label, workflow, _finalize)
characters=characters, checkpoints=checkpoints,
actions=actions, outfits=outfits, scenes=scenes, if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
styles=styles, detailers=detailers, return {'status': 'queued', 'job_id': job['id']}
generated_image=relative_path, selected_char=char_slug, selected_ckpt=checkpoint)
time.sleep(2) flash("Generation queued.")
max_retries -= 1
flash("Generation timed out.")
except Exception as e: except Exception as e:
print(f"Generator error: {e}") print(f"Generator error: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
@@ -2194,7 +2380,6 @@ def generate_image(slug):
try: try:
# Get action type # Get action type
action = request.form.get('action', 'preview') action = request.form.get('action', 'preview')
client_id = request.form.get('client_id')
# Get selected fields # Get selected fields
selected_fields = request.form.getlist('include_field') selected_fields = request.form.getlist('include_field')
@@ -2202,17 +2387,45 @@ def generate_image(slug):
# Save preferences # Save preferences
session[f'prefs_{slug}'] = selected_fields session[f'prefs_{slug}'] = selected_fields
# Queue generation using helper # Build workflow
prompt_response = _queue_generation(character, action, selected_fields, client_id=client_id) 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)
if 'prompt_id' not in prompt_response: # Finalize callback — runs in background thread after ComfyUI finishes
raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") _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
prompt_id = prompt_response['prompt_id'] label = f"{character.name} {action}"
job = _enqueue_job(label, workflow, _finalize)
# Return JSON if AJAX request
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': 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)) return redirect(url_for('detail', slug=slug))
@@ -2510,7 +2723,6 @@ def generate_outfit_image(slug):
try: try:
# Get action type # Get action type
action = request.form.get('action', 'preview') action = request.form.get('action', 'preview')
client_id = request.form.get('client_id')
# Get selected fields # Get selected fields
selected_fields = request.form.getlist('include_field') selected_fields = request.form.getlist('include_field')
@@ -2611,16 +2823,37 @@ def generate_outfit_image(slug):
ckpt_path, ckpt_data = _get_default_checkpoint() ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, outfit=outfit, checkpoint=ckpt_path, checkpoint_data=ckpt_data) workflow = _prepare_workflow(workflow, character, prompts, outfit=outfit, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
prompt_response = queue_prompt(workflow, client_id=client_id) _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
if 'prompt_id' not in prompt_response: char_label = character.name if character else 'no character'
raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") label = f"Outfit: {outfit.name} ({char_label}) {action}"
job = _enqueue_job(label, workflow, _finalize)
prompt_id = prompt_response['prompt_id']
# Return JSON if AJAX request
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': 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)) return redirect(url_for('outfit_detail', slug=slug))
@@ -3027,7 +3260,6 @@ def generate_action_image(slug):
try: try:
# Get action type # Get action type
action_type = request.form.get('action', 'preview') action_type = request.form.get('action', 'preview')
client_id = request.form.get('client_id')
# Get selected fields # Get selected fields
selected_fields = request.form.getlist('include_field') selected_fields = request.form.getlist('include_field')
@@ -3210,15 +3442,37 @@ def generate_action_image(slug):
ckpt_path, ckpt_data = _get_default_checkpoint() ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data)
prompt_response = queue_prompt(workflow, client_id=client_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
if 'prompt_id' not in prompt_response: char_label = character.name if character else 'no character'
raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") label = f"Action: {action_obj.name} ({char_label}) {action_type}"
job = _enqueue_job(label, workflow, _finalize)
prompt_id = prompt_response['prompt_id']
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': 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)) 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)) 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: if character:
combined_data = character.data.copy() combined_data = character.data.copy()
combined_data['character_id'] = character.character_id 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() ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, style=style_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) 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/<path:slug>/generate', methods=['POST']) @app.route('/style/<path:slug>/generate', methods=['POST'])
def generate_style_image(slug): def generate_style_image(slug):
@@ -3742,7 +3997,6 @@ def generate_style_image(slug):
try: try:
# Get action type # Get action type
action = request.form.get('action', 'preview') action = request.form.get('action', 'preview')
client_id = request.form.get('client_id')
# Get selected fields # Get selected fields
selected_fields = request.form.getlist('include_field') 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'char_style_{slug}'] = character_slug
session[f'prefs_style_{slug}'] = selected_fields session[f'prefs_style_{slug}'] = selected_fields
# Queue generation using helper # Build workflow using helper (returns workflow dict, not prompt_response)
prompt_response = _queue_style_generation(style_obj, character, selected_fields, client_id=client_id) workflow = _build_style_workflow(style_obj, character, selected_fields)
if 'prompt_id' not in prompt_response: _action = action
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'])
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
prompt_id = prompt_response['prompt_id'] 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': 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)) return redirect(url_for('style_detail', slug=slug))
@@ -3899,7 +4177,8 @@ def generate_missing_styles():
style_slug = style_obj.slug style_slug = style_obj.slug
try: try:
print(f"Batch generating style: {style_obj.name} with character {character.name}") 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'] prompt_id = prompt_response['prompt_id']
max_retries = 120 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 # For scene generation, we want to ensure Node 20 is handled in _prepare_workflow
ckpt_path, ckpt_data = _get_default_checkpoint() ckpt_path, ckpt_data = _get_default_checkpoint()
workflow = _prepare_workflow(workflow, character, prompts, scene=scene_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) 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/<path:slug>/generate', methods=['POST']) @app.route('/scene/<path:slug>/generate', methods=['POST'])
def generate_scene_image(slug): def generate_scene_image(slug):
@@ -4379,7 +4658,6 @@ def generate_scene_image(slug):
try: try:
# Get action type # Get action type
action = request.form.get('action', 'preview') action = request.form.get('action', 'preview')
client_id = request.form.get('client_id')
# Get selected fields # Get selected fields
selected_fields = request.form.getlist('include_field') 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'char_scene_{slug}'] = character_slug
session[f'prefs_scene_{slug}'] = selected_fields session[f'prefs_scene_{slug}'] = selected_fields
# Queue generation using helper # Build workflow using helper
prompt_response = _queue_scene_generation(scene_obj, character, selected_fields, client_id=client_id) workflow = _queue_scene_generation(scene_obj, character, selected_fields)
if 'prompt_id' not in prompt_response: _action = action
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'])
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
prompt_id = prompt_response['prompt_id'] 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': 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)) 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() 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) 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/<path:slug>/generate', methods=['POST']) @app.route('/detailer/<path:slug>/generate', methods=['POST'])
def generate_detailer_image(slug): def generate_detailer_image(slug):
@@ -4928,8 +5230,7 @@ def generate_detailer_image(slug):
try: try:
# Get action type # Get action type
action = request.form.get('action', 'preview') action_type = request.form.get('action', 'preview')
client_id = request.form.get('client_id')
# Get selected fields # Get selected fields
selected_fields = request.form.getlist('include_field') selected_fields = request.form.getlist('include_field')
@@ -4948,7 +5249,7 @@ def generate_detailer_image(slug):
# Get selected action (if any) # Get selected action (if any)
action_slug = request.form.get('action_slug', '') 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 # Get additional prompts
extra_positive = request.form.get('extra_positive', '').strip() 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'extra_neg_detailer_{slug}'] = extra_negative
session[f'prefs_detailer_{slug}'] = selected_fields session[f'prefs_detailer_{slug}'] = selected_fields
# Queue generation using helper # Build workflow 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) workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative)
if 'prompt_id' not in prompt_response: _action_type = action_type
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'])
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
prompt_id = prompt_response['prompt_id'] 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': 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)) return redirect(url_for('detailer_detail', slug=slug))
@@ -5316,7 +5641,8 @@ def _apply_checkpoint_settings(workflow, ckpt_data):
return workflow 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: with open('comfy_workflow.json', 'r') as f:
workflow = json.load(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, workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_obj.checkpoint_path,
checkpoint_data=ckpt_obj.data or {}) checkpoint_data=ckpt_obj.data or {})
return workflow
return queue_prompt(workflow, client_id=client_id)
@app.route('/checkpoint/<path:slug>/generate', methods=['POST']) @app.route('/checkpoint/<path:slug>/generate', methods=['POST'])
def generate_checkpoint_image(slug): def generate_checkpoint_image(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
try: try:
client_id = request.form.get('client_id')
character_slug = request.form.get('character_slug', '') character_slug = request.form.get('character_slug', '')
character = None character = None
if character_slug == '__random__': if character_slug == '__random__':
@@ -5363,14 +5687,37 @@ def generate_checkpoint_image(slug):
character = Character.query.filter_by(slug=character_slug).first() character = Character.query.filter_by(slug=character_slug).first()
session[f'char_checkpoint_{slug}'] = character_slug 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: _slug = slug
raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") 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': 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)) return redirect(url_for('checkpoint_detail', slug=slug))
except Exception as e: except Exception as e:
print(f"Checkpoint generation error: {e}") print(f"Checkpoint generation error: {e}")
@@ -5648,7 +5995,6 @@ def generate_look_image(slug):
try: try:
action = request.form.get('action', 'preview') action = request.form.get('action', 'preview')
client_id = request.form.get('client_id')
selected_fields = request.form.getlist('include_field') selected_fields = request.form.getlist('include_field')
character_slug = request.form.get('character_slug', '') 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, workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path,
checkpoint_data=ckpt_data, look=look) checkpoint_data=ckpt_data, look=look)
prompt_response = queue_prompt(workflow, client_id=client_id) _action = action
if 'prompt_id' not in prompt_response: _slug = slug
raise Exception(f"ComfyUI failed: {prompt_response.get('error', 'Unknown error')}") 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': 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)) return redirect(url_for('look_detail', slug=slug))
except Exception as e: except Exception as e:
@@ -6533,7 +6903,6 @@ def strengths_generate(category, slug):
try: try:
strength_value = float(request.form.get('strength_value', 1.0)) strength_value = float(request.form.get('strength_value', 1.0))
fixed_seed = int(request.form.get('seed', random.randint(1, 10**15))) 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), # Resolve character: prefer POST body value (reflects current page dropdown),
# then fall back to session. # then fall back to session.
@@ -6584,9 +6953,35 @@ def strengths_generate(category, slug):
custom_negative=extra_negative custom_negative=extra_negative
) )
result = queue_prompt(workflow, client_id) _category = category
prompt_id = result.get('prompt_id', '') _slug = slug
return {'status': 'queued', 'prompt_id': prompt_id} _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: except Exception as e:
print(f"[Strengths] generate error: {e}") print(f"[Strengths] generate error: {e}")

View File

@@ -121,6 +121,74 @@ a:hover { color: #9d98ff; }
opacity: 1; 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 Cards
============================================================ */ ============================================================ */

View File

@@ -326,187 +326,72 @@
} }
}); });
// Generate a unique client ID let currentJobId = null;
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 currentAction = null; let currentAction = null;
socket.addEventListener('message', (event) => { async function waitForJob(jobId) {
const msg = JSON.parse(event.data); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') {
console.log('Finished via Polling'); clearInterval(poll);
checkResolve(); 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); } } catch (err) { console.error('Poll error:', err); }
}, 2000); }, 1500);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') { if (!submitter || submitter.value !== 'preview') return;
return;
}
e.preventDefault(); e.preventDefault();
currentAction = submitter.value; currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', currentAction);
formData.append('client_id', clientId);
// UI Reset
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%';
progressBar.textContent = '0%'; progressBar.textContent = '';
progressLabel.textContent = 'Starting...'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
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
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
if (data.success) { currentJobId = data.job_id;
// Update preview image const jobResult = await waitForJob(currentJobId);
previewImg.src = data.image_url; currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
// Enable the replace cover button if it exists
const replaceBtn = document.getElementById('replace-cover-btn'); const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) { if (replaceBtn) replaceBtn.disabled = false;
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);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Finalize request failed'); alert('Generation failed: ' + err.message);
} finally { } finally {
progressContainer.classList.add('d-none'); progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
} });
// Batch: Generate All Characters // Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/action/{{ action.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
@@ -542,54 +427,43 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) { for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break; if (stopBatch) break;
const char = allCharacters[i]; const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`; batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`; batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const genForm = document.getElementById('generate-form');
const fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%';
progressBar.textContent = '0%'; progressBar.textContent = '';
progressLabel.textContent = `${char.name}: Starting...`; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), { const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
currentPromptId = data.prompt_id; const jobResult = await waitForJob(currentJobId);
await waitForCompletion(currentPromptId); currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
progressLabel.textContent = 'Saving image...'; addToPreviewGallery(jobResult.result.image_url, char.name);
const finalFD = new FormData(); previewImg.src = jobResult.result.image_url;
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;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
} }
currentPromptId = null;
} catch (err) { } catch (err) {
console.error(`Failed for ${char.name}:`, err); console.error(`Failed for ${char.name}:`, err);
currentPromptId = null; currentJobId = null;
} finally { } finally {
progressContainer.classList.add('d-none'); progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
} }
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;

View File

@@ -102,71 +102,18 @@
const itemNameText = document.getElementById('current-item-name'); const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'actions_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -184,67 +131,64 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // 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) { for (const item of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
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');
try { try {
// Random character for action preview
const genResp = await fetch(`/action/${item.slug}/generate`, { const genResp = await fetch(`/action/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); 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}`, { let completed = 0;
method: 'POST', await Promise.all(jobs.map(async ({ item, jobId }) => {
body: new URLSearchParams({ 'action': 'replace' }) try {
}); const jobResult = await waitForJob(jobId);
const finData = await finResp.json(); if (jobResult.result && jobResult.result.image_url) {
if (finData.success) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
img.src = finData.image_url;
img.classList.remove('d-none');
}
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; 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.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Action Generation Complete!"; statusText.textContent = 'Batch Action Generation Complete!';
itemNameText.textContent = ""; itemNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = "";
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -261,52 +261,20 @@
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); const previewImg = document.getElementById('preview-img');
const clientId = 'checkpoint_detail_' + Math.random().toString(36).substring(2, 15); let currentJobId = null;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
const nodeNames = { async function waitForJob(jobId) {
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", return new Promise((resolve, reject) => {
"4": "Loading Models", "16": "Character LoRA", "17": "Outfit LoRA", const poll = setInterval(async () => {
"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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -314,76 +282,36 @@
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', 'preview'); formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
alert('Error: ' + data.error); currentJobId = data.job_id;
progressContainer.classList.add('d-none'); const jobResult = await waitForJob(currentJobId);
return; currentJobId = null;
} if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
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 (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn'); const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false; 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 // Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/checkpoint/{{ ckpt.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
@@ -419,53 +347,36 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) { for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break; if (stopBatch) break;
const char = allCharacters[i]; const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`; batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`; batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const genForm = document.getElementById('generate-form');
const fd = new FormData(); const fd = new FormData();
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Starting...`; progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), { const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
currentPromptId = data.prompt_id; const jobResult = await waitForJob(currentJobId);
await waitForCompletion(currentPromptId); currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
progressLabel.textContent = 'Saving image...'; addToPreviewGallery(jobResult.result.image_url, char.name);
const finalFD = new FormData(); previewImg.src = jobResult.result.image_url;
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;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
} }
currentPromptId = null; } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
} catch (err) { finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
} }
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;

View File

@@ -84,55 +84,20 @@
const ckptNameText = document.getElementById('current-ckpt-name'); const ckptNameText = document.getElementById('current-ckpt-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'checkpoints_batch_' + Math.random().toString(36).substring(2, 15); let currentJobId = null;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
const nodeNames = { async function waitForJob(jobId) {
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", return new Promise((resolve, reject) => {
"4": "Loading Models", "16": "Character LoRA", "17": "Outfit LoRA", const poll = setInterval(async () => {
"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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -157,36 +122,32 @@
progressBar.textContent = `${percent}%`; progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`; statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`;
ckptNameText.textContent = `Current: ${ckpt.name}`; ckptNameText.textContent = `Current: ${ckpt.name}`;
nodeStatus.textContent = 'Queuing...'; nodeStatus.textContent = 'Queuing';
taskProgressBar.style.width = '100%'; taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued'; taskProgressBar.textContent = '';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try { try {
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, { const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ 'client_id': clientId, 'character_slug': '__random__' }), body: new URLSearchParams({ 'character_slug': '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); 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}`, { if (jobResult.result && jobResult.result.image_url) {
method: 'POST',
body: new URLSearchParams({ 'action': 'replace' })
});
const finData = await finResp.json();
if (finData.success) {
const img = document.getElementById(`img-${ckpt.slug}`); const img = document.getElementById(`img-${ckpt.slug}`);
const noImgSpan = document.getElementById(`no-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'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${ckpt.name}:`, err); console.error(`Failed for ${ckpt.name}:`, err);
currentJobId = null;
} }
completed++; completed++;
} }
@@ -196,7 +157,6 @@
statusText.textContent = 'Batch Generation Complete!'; statusText.textContent = 'Batch Generation Complete!';
ckptNameText.textContent = ''; ckptNameText.textContent = '';
nodeStatus.textContent = 'Done'; nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;

View File

@@ -228,82 +228,28 @@
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); const previewImg = document.getElementById('preview-img');
// Generate a unique client ID let currentJobId = null;
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 currentAction = null; let currentAction = null;
socket.addEventListener('message', (event) => { async function waitForJob(jobId) {
const msg = JSON.parse(event.data); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') {
console.log('Finished via Polling'); clearInterval(poll);
checkResolve(); 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); } } catch (err) { console.error('Poll error:', err); }
}, 2000); }, 1500);
}); });
} }
@@ -319,21 +265,19 @@
currentAction = submitter.value; currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', currentAction);
formData.append('client_id', clientId);
// UI Reset // UI Reset
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%';
progressBar.textContent = '0%'; progressBar.textContent = '';
progressLabel.textContent = 'Starting...'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers: { headers: { 'X-Requested-With': 'XMLHttpRequest' }
'X-Requested-With': 'XMLHttpRequest'
}
}); });
const data = await response.json(); const data = await response.json();
@@ -344,64 +288,29 @@
return; return;
} }
currentPromptId = data.prompt_id; currentJobId = data.job_id;
progressLabel.textContent = 'Queued...'; 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) // Wait for the background worker to finish
await waitForCompletion(currentPromptId); const jobResult = await waitForJob(currentJobId);
currentJobId = null;
// Finalize // Image is already saved — just display it
finalizeGeneration(currentPromptId, currentAction); if (jobResult.result && jobResult.result.image_url) {
currentPromptId = null; previewImg.src = jobResult.result.image_url;
} 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;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
// Enable the replace cover button if it exists
const replaceBtn = document.getElementById('replace-cover-btn'); const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) { if (replaceBtn) replaceBtn.disabled = false;
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);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Finalize request failed'); alert('Generation failed: ' + err.message);
} finally { } finally {
progressContainer.classList.add('d-none'); progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
} });
}); });
// Image modal function // Image modal function

View File

@@ -296,171 +296,59 @@
} }
}); });
// Generate a unique client ID let currentJobId = null;
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 currentAction = null; let currentAction = null;
socket.addEventListener('message', (event) => { async function waitForJob(jobId) {
const msg = JSON.parse(event.data); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
console.log('Finished via Polling'); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
checkResolve(); else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
} else progressLabel.textContent = 'Queued…';
} catch (err) { console.error('Polling error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 2000); }, 1500);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
currentAction = submitter.value; currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', currentAction);
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
alert('Error: ' + data.error); currentJobId = data.job_id;
progressContainer.classList.add('d-none'); const jobResult = await waitForJob(currentJobId);
return; currentJobId = null;
} if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
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 (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn'); const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) { if (replaceBtn) replaceBtn.disabled = false;
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);
}
} 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 // Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/detailer/{{ detailer.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
@@ -496,13 +384,11 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) { for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break; if (stopBatch) break;
const char = allCharacters[i]; const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`; batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`; batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const genForm = document.getElementById('generate-form');
const fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
@@ -511,42 +397,27 @@
fd.append('extra_positive', document.getElementById('extra_positive').value); fd.append('extra_positive', document.getElementById('extra_positive').value);
fd.append('extra_negative', document.getElementById('extra_negative').value); fd.append('extra_negative', document.getElementById('extra_negative').value);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Starting...`; progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), { const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
currentPromptId = data.prompt_id; const jobResult = await waitForJob(currentJobId);
await waitForCompletion(currentPromptId); currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
progressLabel.textContent = 'Saving image...'; addToPreviewGallery(jobResult.result.image_url, char.name);
const finalFD = new FormData(); previewImg.src = jobResult.result.image_url;
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;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
} }
currentPromptId = null; } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
} catch (err) { finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
} }
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;

View File

@@ -104,72 +104,18 @@
const detailerNameText = document.getElementById('current-detailer-name'); const detailerNameText = document.getElementById('current-detailer-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'detailers_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -187,66 +133,65 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // 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) { for (const item of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
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');
try { try {
const genResp = await fetch(`/detailer/${item.slug}/generate`, { const genResp = await fetch(`/detailer/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); 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}`, { let completed = 0;
method: 'POST', await Promise.all(jobs.map(async ({ item, jobId }) => {
body: new URLSearchParams({ 'action': 'replace' }) try {
}); const jobResult = await waitForJob(jobId);
const finData = await finResp.json(); if (jobResult.result && jobResult.result.image_url) {
if (finData.success) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
img.src = finData.image_url;
img.classList.remove('d-none');
}
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; 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.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Detailer Generation Complete!"; statusText.textContent = 'Batch Detailer Generation Complete!';
detailerNameText.textContent = ""; detailerNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -310,73 +310,24 @@
const placeholder = document.getElementById('placeholder-text'); const placeholder = document.getElementById('placeholder-text');
const resultFooter = document.getElementById('result-footer'); const resultFooter = document.getElementById('result-footer');
const clientId = 'generator_view_' + Math.random().toString(36).substring(2, 15); let currentJobId = null;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
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; let stopRequested = false;
socket.addEventListener('message', (event) => { async function waitForJob(jobId) {
const msg = JSON.parse(event.data); return new Promise((resolve, reject) => {
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;
const poll = setInterval(async () => { const poll = setInterval(async () => {
try { try {
const r = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
if ((await r.json()).status === 'finished') done(); const data = await resp.json();
} catch (_) {} if (data.status === 'done') { clearInterval(poll); resolve(data); }
}, 2000); 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) { function setGeneratingState(active) {
generateBtn.disabled = active; generateBtn.disabled = active;
endlessBtn.disabled = active; endlessBtn.disabled = active;
@@ -388,12 +339,11 @@
if (document.getElementById('lucky-dip').checked) applyLuckyDip(); if (document.getElementById('lucky-dip').checked) applyLuckyDip();
progressCont.classList.remove('d-none'); progressCont.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLbl.textContent = label; progressLbl.textContent = label;
const fd = new FormData(form); const fd = new FormData(form);
fd.append('client_id', clientId);
const resp = await fetch(form.action, { const resp = await fetch(form.action, {
method: 'POST', body: fd, method: 'POST', body: fd,
@@ -402,15 +352,19 @@
const data = await resp.json(); const data = await resp.json();
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
currentPromptId = data.prompt_id; currentJobId = data.job_id;
progressLbl.textContent = 'Queued...'; progressLbl.textContent = 'Queued';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
await waitForCompletion(currentPromptId); const jobResult = await waitForJob(currentJobId);
await finalizeGeneration(document.getElementById('character').value, currentPromptId); currentJobId = null;
currentPromptId = 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) { async function runLoop(endless) {

View File

@@ -103,72 +103,18 @@
const charNameText = document.getElementById('current-char-name'); const charNameText = document.getElementById('current-char-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'gallery_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -186,62 +132,65 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // 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) { for (const char of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
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');
try { try {
const genResp = await fetch(`/character/${char.slug}/generate`, { const genResp = await fetch(`/character/${char.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ 'action': 'replace', 'client_id': clientId }), body: new URLSearchParams({ action: 'replace' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item: char, jobId: genData.job_id });
} catch (err) {
await waitForCompletion(currentPromptId); console.error(`Failed to queue ${char.name}:`, err);
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');
} }
}
// 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`;
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'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${char.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; 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.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Complete!"; statusText.textContent = 'Batch Complete!';
charNameText.textContent = ""; charNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -29,6 +29,12 @@
<a href="/gallery" class="btn btn-sm btn-outline-light">Gallery</a> <a href="/gallery" class="btn btn-sm btn-outline-light">Gallery</a>
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a> <a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div> <div class="vr mx-1 d-none d-lg-block"></div>
<!-- Queue indicator -->
<button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Generation Queue">
<span class="queue-icon"></span>
<span id="queue-count-badge" class="queue-badge d-none">0</span>
</button>
<div class="vr mx-1 d-none d-lg-block"></div>
<!-- Service status indicators --> <!-- Service status indicators -->
<span id="status-comfyui" class="service-status" title="ComfyUI" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="ComfyUI: checking…"> <span id="status-comfyui" class="service-status" title="ComfyUI" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="ComfyUI: checking…">
<span class="status-dot status-checking"></span> <span class="status-dot status-checking"></span>
@@ -94,6 +100,31 @@
</div> </div>
</div> </div>
<!-- Generation Queue Modal -->
<div class="modal fade" id="queueModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Generation Queue
<span id="queue-modal-count" class="badge bg-secondary ms-2">0</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="queue-empty-msg" class="text-center text-muted py-4">
<p class="mb-0">No jobs in queue.</p>
</div>
<ul id="queue-job-list" class="list-group list-group-flush d-none"></ul>
</div>
<div class="modal-footer">
<small class="text-muted me-auto">Jobs are processed sequentially. Close this window to continue browsing.</small>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -339,6 +370,148 @@
}); });
})(); })();
</script> </script>
<script>
// ---- Generation Queue UI ----
(function() {
const badge = document.getElementById('queue-count-badge');
const modalCount = document.getElementById('queue-modal-count');
const jobList = document.getElementById('queue-job-list');
const emptyMsg = document.getElementById('queue-empty-msg');
const STATUS_LABELS = {
pending: { text: 'Pending', cls: 'text-muted' },
processing: { text: 'Generating…', cls: 'text-warning' },
paused: { text: 'Paused', cls: 'text-secondary' },
done: { text: 'Done', cls: 'text-success' },
failed: { text: 'Failed', cls: 'text-danger' },
removed: { text: 'Removed', cls: 'text-muted' },
};
function renderQueue(jobs) {
const activeJobs = jobs.filter(j => !['done', 'failed', 'removed'].includes(j.status));
const count = activeJobs.length;
// Update badge
if (count > 0) {
badge.textContent = count;
badge.classList.remove('d-none');
document.getElementById('queue-btn').classList.add('queue-btn-active');
} else {
badge.classList.add('d-none');
document.getElementById('queue-btn').classList.remove('queue-btn-active');
}
// Update modal count
if (modalCount) modalCount.textContent = jobs.length;
// Render job list
if (!jobList) return;
if (jobs.length === 0) {
jobList.classList.add('d-none');
if (emptyMsg) emptyMsg.classList.remove('d-none');
return;
}
jobList.classList.remove('d-none');
if (emptyMsg) emptyMsg.classList.add('d-none');
jobList.innerHTML = '';
jobs.forEach(job => {
const li = document.createElement('li');
li.className = 'list-group-item d-flex align-items-center gap-2 py-2';
li.id = `queue-job-${job.id}`;
const statusInfo = STATUS_LABELS[job.status] || { text: job.status, cls: 'text-muted' };
// Status indicator
const statusDot = document.createElement('span');
statusDot.className = `queue-status-dot queue-status-${job.status}`;
li.appendChild(statusDot);
// Label
const label = document.createElement('span');
label.className = 'flex-grow-1 small';
label.textContent = job.label;
li.appendChild(label);
// Status text
const statusText = document.createElement('span');
statusText.className = `small ${statusInfo.cls}`;
statusText.textContent = statusInfo.text;
if (job.status === 'failed' && job.error) {
statusText.title = job.error;
statusText.style.cursor = 'help';
}
li.appendChild(statusText);
// Action buttons
const btnGroup = document.createElement('div');
btnGroup.className = 'd-flex gap-1';
if (job.status === 'pending') {
const pauseBtn = document.createElement('button');
pauseBtn.className = 'btn btn-xs btn-outline-secondary';
pauseBtn.textContent = '⏸';
pauseBtn.title = 'Pause';
pauseBtn.onclick = () => queuePause(job.id);
btnGroup.appendChild(pauseBtn);
}
if (job.status === 'paused') {
const resumeBtn = document.createElement('button');
resumeBtn.className = 'btn btn-xs btn-outline-success';
resumeBtn.textContent = '▶';
resumeBtn.title = 'Resume';
resumeBtn.onclick = () => queuePause(job.id);
btnGroup.appendChild(resumeBtn);
}
if (['pending', 'paused', 'failed'].includes(job.status)) {
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-xs btn-outline-danger';
removeBtn.textContent = '✕';
removeBtn.title = 'Remove';
removeBtn.onclick = () => queueRemove(job.id);
btnGroup.appendChild(removeBtn);
}
li.appendChild(btnGroup);
jobList.appendChild(li);
});
}
async function fetchQueue() {
try {
const resp = await fetch('/api/queue');
const data = await resp.json();
renderQueue(data.jobs || []);
} catch (e) {}
}
async function queueRemove(jobId) {
try {
await fetch(`/api/queue/${jobId}/remove`, { method: 'POST' });
fetchQueue();
} catch (e) {}
}
async function queuePause(jobId) {
try {
await fetch(`/api/queue/${jobId}/pause`, { method: 'POST' });
fetchQueue();
} catch (e) {}
}
// Poll queue every 2 seconds
document.addEventListener('DOMContentLoaded', () => {
fetchQueue();
setInterval(fetchQueue, 2000);
// Refresh when modal opens
const queueModal = document.getElementById('queueModal');
if (queueModal) {
queueModal.addEventListener('show.bs.modal', fetchQueue);
}
});
})();
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -239,45 +239,20 @@
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); const previewImg = document.getElementById('preview-img');
const clientId = 'look_detail_' + Math.random().toString(36).substring(2, 15); let currentJobId = null;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
const nodeNames = { async function waitForJob(jobId) {
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", return new Promise((resolve, reject) => {
"4": "Loading Models", "16": "Look LoRA", "17": "Outfit LoRA", const poll = setInterval(async () => {
"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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') checkResolve(); if (data.status === 'done') { clearInterval(poll); resolve(data); }
} catch (err) { console.error('Polling error:', err); } else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
}, 2000); 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; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', 'preview'); formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData, method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; } if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
currentPromptId = data.prompt_id; const jobResult = await waitForJob(currentJobId);
progressLabel.textContent = 'Queued...'; currentJobId = null;
progressBar.style.width = '100%'; if (jobResult.result && jobResult.result.image_url) {
progressBar.textContent = 'Queued'; previewImg.src = jobResult.result.image_url;
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;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn'); const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false; 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) { function showImage(src) {

View File

@@ -105,56 +105,20 @@
const itemNameText = document.getElementById('current-item-name'); const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'looks_batch_' + Math.random().toString(36).substring(2, 15); let currentJobId = null;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
const nodeNames = { async function waitForJob(jobId) {
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", return new Promise((resolve, reject) => {
"4": "Loading Models", "16": "Look LoRA", "17": "Outfit LoRA", const poll = setInterval(async () => {
"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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -179,40 +143,32 @@
progressBar.textContent = `${percent}%`; progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating Looks: ${completed + 1} / ${missing.length}`; statusText.textContent = `Batch Generating Looks: ${completed + 1} / ${missing.length}`;
itemNameText.textContent = `Current: ${item.name}`; itemNameText.textContent = `Current: ${item.name}`;
nodeStatus.textContent = "Queuing..."; nodeStatus.textContent = "Queuing";
taskProgressBar.style.width = '100%'; taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued'; taskProgressBar.textContent = '';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try { try {
// Looks are self-contained — no character_slug passed
const genResp = await fetch(`/look/${item.slug}/generate`, { const genResp = await fetch(`/look/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ 'action': 'replace' }),
'action': 'replace',
'client_id': clientId
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); 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}`, { if (jobResult.result && jobResult.result.image_url) {
method: 'POST',
body: new URLSearchParams({ 'action': 'replace' })
});
const finData = await finResp.json();
if (finData.success) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-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'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
currentJobId = null;
} }
completed++; completed++;
} }

View File

@@ -272,82 +272,28 @@
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); const previewImg = document.getElementById('preview-img');
// Generate a unique client ID let currentJobId = null;
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 currentAction = null; let currentAction = null;
socket.addEventListener('message', (event) => { async function waitForJob(jobId) {
const msg = JSON.parse(event.data); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') {
console.log('Finished via Polling'); clearInterval(poll);
checkResolve(); 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); } } catch (err) { console.error('Poll error:', err); }
}, 2000); }, 1500);
}); });
} }
@@ -363,21 +309,19 @@
currentAction = submitter.value; currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', currentAction);
formData.append('client_id', clientId);
// UI Reset // UI Reset
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%';
progressBar.textContent = '0%'; progressBar.textContent = '';
progressLabel.textContent = 'Starting...'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers: { headers: { 'X-Requested-With': 'XMLHttpRequest' }
'X-Requested-With': 'XMLHttpRequest'
}
}); });
const data = await response.json(); const data = await response.json();
@@ -388,71 +332,33 @@
return; return;
} }
currentPromptId = data.prompt_id; currentJobId = data.job_id;
progressLabel.textContent = 'Queued...'; 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) const jobResult = await waitForJob(currentJobId);
await waitForCompletion(currentPromptId); currentJobId = null;
// Finalize if (jobResult.result && jobResult.result.image_url) {
finalizeGeneration(currentPromptId, currentAction); previewImg.src = jobResult.result.image_url;
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;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
// Enable the replace cover button if it exists
const replaceBtn = document.getElementById('replace-cover-btn'); const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) { if (replaceBtn) replaceBtn.disabled = false;
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);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Finalize request failed'); alert('Generation failed: ' + err.message);
} finally { } finally {
progressContainer.classList.add('d-none'); progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
} });
// Batch: Generate All Characters // Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/outfit/{{ outfit.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -497,35 +403,31 @@
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%';
progressBar.textContent = '0%'; progressBar.textContent = '';
progressLabel.textContent = `${char.name}: Starting...`; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), { const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentPromptId = data.prompt_id; currentJobId = data.job_id;
await waitForCompletion(currentPromptId); const jobResult = await waitForJob(currentJobId);
progressLabel.textContent = 'Saving image...'; currentJobId = null;
const finalFD = new FormData(); if (jobResult.result && jobResult.result.image_url) {
finalFD.append('action', 'preview'); addToPreviewGallery(jobResult.result.image_url, char.name);
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); previewImg.src = jobResult.result.image_url;
const finalData = await finalResp.json();
if (finalData.success) {
addToPreviewGallery(finalData.image_url, char.name);
previewImg.src = finalData.image_url;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
} }
currentPromptId = null;
} catch (err) { } catch (err) {
console.error(`Failed for ${char.name}:`, err); console.error(`Failed for ${char.name}:`, err);
currentPromptId = null; currentJobId = null;
} finally { } finally {
progressContainer.classList.add('d-none'); progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
} }
batchBar.style.width = '100%'; batchBar.style.width = '100%';

View File

@@ -102,71 +102,18 @@
const itemNameText = document.getElementById('current-item-name'); const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'outfits_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -184,66 +131,65 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // 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) { for (const item of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
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');
try { try {
const genResp = await fetch(`/outfit/${item.slug}/generate`, { const genResp = await fetch(`/outfit/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); 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}`, { let completed = 0;
method: 'POST', await Promise.all(jobs.map(async ({ item, jobId }) => {
body: new URLSearchParams({ 'action': 'replace' }) try {
}); const jobResult = await waitForJob(jobId);
const finData = await finResp.json(); if (jobResult.result && jobResult.result.image_url) {
if (finData.success) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
img.src = finData.image_url;
img.classList.remove('d-none');
}
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; 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.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Outfit Generation Complete!"; statusText.textContent = 'Batch Outfit Generation Complete!';
itemNameText.textContent = ""; itemNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -101,11 +101,10 @@
const SG_CAT = {{ sg_category | tojson }}; const SG_CAT = {{ sg_category | tojson }};
const SG_SLUG = {{ sg_entity.slug | 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 sgRunning = false;
let sgShouldStop = false; let sgShouldStop = false;
let sgQueuedJobs = []; // track all queued job IDs so stop can cancel them
// ---- helpers ---- // ---- helpers ----
@@ -241,52 +240,23 @@
sgHighlightBounds(); sgHighlightBounds();
} }
// ---- WebSocket wait ---- // ---- Job queue wait ----
function sgWaitForCompletion(promptId) { function sgWaitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let ws; const poll = setInterval(async () => {
try { try {
ws = new WebSocket(`${SG_WS}?clientId=${SG_CLIENT_ID}`); const resp = await fetch(`/api/queue/${jobId}/status`);
} catch (e) { const data = await resp.json();
// Fall back to polling if WS unavailable if (data.status === 'done') { clearInterval(poll); resolve(data); }
sgPollUntilDone(promptId).then(resolve).catch(reject); else if (data.status === 'failed' || data.status === 'removed') {
return; clearInterval(poll); reject(new Error(data.error || 'Job failed'));
} }
} catch (err) { console.error('[Strengths] poll error:', err); }
const timeout = setTimeout(() => { }, 1500);
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();
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
sgPollUntilDone(promptId).then(resolve).catch(reject);
};
}); });
} }
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 ---- // ---- main flow ----
async function sgClearImages() { async function sgClearImages() {
@@ -310,64 +280,65 @@
const steps = sgBuildSteps(min, max, sgGetInterval()); const steps = sgBuildSteps(min, max, sgGetInterval());
if (!steps.length) return; if (!steps.length) return;
// Clear any previous set before starting a new one
await sgClearImages(); await sgClearImages();
sgRunning = true; sgRunning = true;
sgShouldStop = false; sgShouldStop = false;
sgQueuedJobs = [];
document.getElementById('sg-btn-run').classList.add('d-none'); document.getElementById('sg-btn-run').classList.add('d-none');
document.getElementById('sg-btn-stop').classList.remove('d-none'); document.getElementById('sg-btn-stop').classList.remove('d-none');
document.getElementById('sg-progress').classList.remove('d-none'); document.getElementById('sg-progress').classList.remove('d-none');
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}`;
try {
// Queue one generation
// Pick up the character currently selected on this detail page (if any)
const charSelect = document.getElementById('character_select'); const charSelect = document.getElementById('character_select');
const charSlug = charSelect ? charSelect.value : ''; const charSlug = charSelect ? charSelect.value : '';
const formData = new URLSearchParams({
strength_value: sv, // Phase 1: Queue all steps upfront so generation continues even if the page is navigated away
seed: seed, document.getElementById('sg-progress-bar').style.width = '100%';
client_id: SG_CLIENT_ID, document.getElementById('sg-progress-bar').classList.add('progress-bar-striped', 'progress-bar-animated');
character_slug: charSlug, for (let i = 0; i < steps.length; i++) {
}); if (sgShouldStop) break;
const sv = steps[i];
document.getElementById('sg-progress-label').textContent =
`Queuing ${i + 1} / ${steps.length} \u2014 weight: ${sv}`;
try {
const formData = new URLSearchParams({ strength_value: sv, seed, character_slug: charSlug });
const queueResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/generate`, { const queueResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/generate`, {
method: 'POST', method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData, body: formData,
}); });
const queueData = await queueResp.json(); const queueData = await queueResp.json();
if (!queueData.prompt_id) throw new Error('No prompt_id returned'); if (!queueData.job_id) throw new Error('No job_id returned');
sgQueuedJobs.push({ jobId: queueData.job_id, sv });
} catch (err) {
console.error('[Strengths] queue error:', sv, err);
}
}
await sgWaitForCompletion(queueData.prompt_id); // 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%';
// Finalize let completed = 0;
const finData = new URLSearchParams({ strength_value: sv, seed: seed }); await Promise.all(sgQueuedJobs.map(async ({ jobId, sv }) => {
const finResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/finalize/${queueData.prompt_id}`, { try {
method: 'POST', const jobResult = await sgWaitForJob(jobId);
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, if (jobResult.result && jobResult.result.image_url) {
body: finData, sgAddImage(jobResult.result.image_url, sv);
});
const finJson = await finResp.json();
if (finJson.success && finJson.image_url) {
sgAddImage(finJson.image_url, sv);
} }
} catch (err) { } catch (err) {
console.error('[Strengths] step error:', sv, 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-bar').style.width = '100%';
document.getElementById('sg-progress-label').textContent = 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); setTimeout(() => document.getElementById('sg-progress').classList.add('d-none'), 3000);
document.getElementById('sg-btn-stop').classList.add('d-none'); document.getElementById('sg-btn-stop').classList.add('d-none');
@@ -377,6 +348,10 @@
function sgStop() { function sgStop() {
sgShouldStop = true; 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-stop').classList.add('d-none');
document.getElementById('sg-btn-run').classList.remove('d-none'); document.getElementById('sg-btn-run').classList.remove('d-none');
} }

View File

@@ -292,173 +292,57 @@
} }
}); });
// Generate a unique client ID let currentJobId = null;
const clientId = 'scene_detail_' + Math.random().toString(36).substring(2, 15);
// ComfyUI WebSocket async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
console.log('Finished via Polling'); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
checkResolve(); else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
} else progressLabel.textContent = 'Queued…';
} catch (err) { console.error('Polling error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 2000); }, 1500);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', 'preview'); formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
alert('Error: ' + data.error); currentJobId = data.job_id;
progressContainer.classList.add('d-none'); const jobResult = await waitForJob(currentJobId);
return; currentJobId = null;
} if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
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 (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn'); const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) { if (replaceBtn) replaceBtn.disabled = false;
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);
}
} 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 // Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/scene/{{ scene.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
@@ -494,54 +378,37 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) { for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break; if (stopBatch) break;
const char = allCharacters[i]; const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`; batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`; batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const genForm = document.getElementById('generate-form');
const fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Starting...`; progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), { const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
currentPromptId = data.prompt_id; const jobResult = await waitForJob(currentJobId);
await waitForCompletion(currentPromptId); currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
progressLabel.textContent = 'Saving image...'; addToPreviewGallery(jobResult.result.image_url, char.name);
const finalFD = new FormData(); previewImg.src = jobResult.result.image_url;
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;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
} }
currentPromptId = null; } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
} catch (err) { finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
} }
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;

View File

@@ -102,71 +102,18 @@
const sceneNameText = document.getElementById('current-scene-name'); const sceneNameText = document.getElementById('current-scene-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'scenes_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -184,65 +131,64 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // 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) { for (const scene of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
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');
try { try {
const genResp = await fetch(`/scene/${scene.slug}/generate`, { const genResp = await fetch(`/scene/${scene.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item: scene, jobId: genData.job_id });
} catch (err) {
await waitForCompletion(currentPromptId); console.error(`Failed to queue ${scene.name}:`, err);
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');
} }
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
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'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${scene.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; 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.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Scene Generation Complete!"; statusText.textContent = 'Batch Scene Generation Complete!';
sceneNameText.textContent = ""; sceneNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -285,170 +285,59 @@
} }
}); });
// Generate a unique client ID let currentJobId = null;
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 currentAction = null; let currentAction = null;
socket.addEventListener('message', (event) => { async function waitForJob(jobId) {
const msg = JSON.parse(event.data); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); 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…';
} catch (err) { console.error('Polling error:', err); } else progressLabel.textContent = 'Queued…';
}, 2000); } catch (err) { console.error('Poll error:', err); }
}, 1500);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
currentAction = submitter.value; currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', currentAction);
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
alert('Error: ' + data.error); currentJobId = data.job_id;
progressContainer.classList.add('d-none'); const jobResult = await waitForJob(currentJobId);
return; currentJobId = null;
} if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
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 (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn'); const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) { if (replaceBtn) replaceBtn.disabled = false;
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);
}
} 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 // Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/style/{{ style.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
@@ -484,54 +373,37 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) { for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break; if (stopBatch) break;
const char = allCharacters[i]; const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`; batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`; batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const genForm = document.getElementById('generate-form');
const fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Starting...`; progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), { const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
currentPromptId = data.prompt_id; const jobResult = await waitForJob(currentJobId);
await waitForCompletion(currentPromptId); currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
progressLabel.textContent = 'Saving image...'; addToPreviewGallery(jobResult.result.image_url, char.name);
const finalFD = new FormData(); previewImg.src = jobResult.result.image_url;
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;
if (previewCard) previewCard.classList.remove('d-none'); if (previewCard) previewCard.classList.remove('d-none');
} }
currentPromptId = null; } catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
} catch (err) { finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
} }
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;

View File

@@ -102,71 +102,18 @@
const styleNameText = document.getElementById('current-style-name'); const styleNameText = document.getElementById('current-style-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'styles_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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 () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); 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) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -184,61 +131,60 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // 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) { for (const style of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
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');
try { try {
const genResp = await fetch(`/style/${style.slug}/generate`, { const genResp = await fetch(`/style/${style.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item: style, jobId: genData.job_id });
} catch (err) {
await waitForCompletion(currentPromptId); console.error(`Failed to queue ${style.name}:`, err);
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');
} }
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
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'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${style.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; 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.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Style Generation Complete!"; statusText.textContent = 'Batch Style Generation Complete!';
styleNameText.textContent = ""; styleNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;