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:
@@ -227,83 +227,29 @@
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
|
||||
// Generate a unique client ID
|
||||
const clientId = 'detail_view_' + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// ComfyUI WebSocket
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character LoRA",
|
||||
"17": "Outfit LoRA",
|
||||
"18": "Action LoRA",
|
||||
"19": "Style/Detailer LoRA",
|
||||
"8": "Decoding Image",
|
||||
"9": "Saving Image"
|
||||
};
|
||||
|
||||
let currentPromptId = null;
|
||||
let currentJobId = null;
|
||||
let currentAction = null;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
if (!currentPromptId) {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / max) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
}
|
||||
else if (msg.type === 'executing') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
// Execution finished via WebSocket
|
||||
console.log('Finished via WebSocket');
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
const nodeName = nodeNames[nodeId] || `Processing...`;
|
||||
progressLabel.textContent = nodeName;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let resolveCompletion = null;
|
||||
async function waitForCompletion(promptId) {
|
||||
return new Promise((resolve) => {
|
||||
const checkResolve = () => {
|
||||
clearInterval(pollInterval);
|
||||
resolve();
|
||||
};
|
||||
resolveCompletion = checkResolve;
|
||||
|
||||
// Fallback polling in case WebSocket is blocked (403)
|
||||
const pollInterval = setInterval(async () => {
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/check_status/${promptId}`);
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'finished') {
|
||||
console.log('Finished via Polling');
|
||||
checkResolve();
|
||||
if (data.status === 'done') {
|
||||
clearInterval(poll);
|
||||
resolve(data);
|
||||
} else if (data.status === 'failed' || data.status === 'removed') {
|
||||
clearInterval(poll);
|
||||
reject(new Error(data.error || 'Job failed'));
|
||||
} else if (data.status === 'processing') {
|
||||
progressLabel.textContent = 'Generating…';
|
||||
} else {
|
||||
progressLabel.textContent = 'Queued…';
|
||||
}
|
||||
} catch (err) { console.error('Polling error:', err); }
|
||||
}, 2000);
|
||||
} catch (err) { console.error('Poll error:', err); }
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,7 +257,7 @@
|
||||
// Only intercept generate actions
|
||||
const submitter = e.submitter;
|
||||
if (!submitter || submitter.value !== 'preview') {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
@@ -319,21 +265,19 @@
|
||||
currentAction = submitter.value;
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', currentAction);
|
||||
formData.append('client_id', clientId);
|
||||
|
||||
// UI Reset
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.textContent = '0%';
|
||||
progressLabel.textContent = 'Starting...';
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = 'Queuing…';
|
||||
|
||||
try {
|
||||
const response = await fetch(form.getAttribute('action'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -344,64 +288,29 @@
|
||||
return;
|
||||
}
|
||||
|
||||
currentPromptId = data.prompt_id;
|
||||
progressLabel.textContent = 'Queued...';
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = 'Queued';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
// Wait for completion (WebSocket or Polling)
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
// Finalize
|
||||
finalizeGeneration(currentPromptId, currentAction);
|
||||
currentPromptId = null;
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Request failed');
|
||||
progressContainer.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
async function finalizeGeneration(promptId, action) {
|
||||
progressLabel.textContent = 'Saving image...';
|
||||
const url = `/character/{{ character.slug }}/finalize_generation/${promptId}`;
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'preview'); // Always save as preview
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update preview image
|
||||
previewImg.src = data.image_url;
|
||||
currentJobId = data.job_id;
|
||||
progressLabel.textContent = 'Queued…';
|
||||
|
||||
// Wait for the background worker to finish
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
|
||||
// Image is already saved — just display it
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
|
||||
// Enable the replace cover button if it exists
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) {
|
||||
replaceBtn.disabled = false;
|
||||
// Check if there's a form to update
|
||||
const form = replaceBtn.closest('form');
|
||||
if (form) {
|
||||
form.action = `/character/{{ character.slug }}/replace_cover_from_preview`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert('Save failed: ' + data.error);
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Finalize request failed');
|
||||
alert('Generation failed: ' + err.message);
|
||||
} finally {
|
||||
progressContainer.classList.add('d-none');
|
||||
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Image modal function
|
||||
|
||||
Reference in New Issue
Block a user