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:
@@ -261,52 +261,20 @@
|
||||
const previewCard = document.getElementById('preview-card');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
|
||||
const clientId = 'checkpoint_detail_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
let currentJobId = null;
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing",
|
||||
"4": "Loading Models", "16": "Character LoRA", "17": "Outfit LoRA",
|
||||
"18": "Action LoRA", "19": "Style/Detailer LoRA", "8": "Decoding Image", "9": "Saving Image"
|
||||
};
|
||||
|
||||
let currentPromptId = null;
|
||||
let resolveCompletion = null;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'status') {
|
||||
if (!currentPromptId) {
|
||||
const q = msg.data.status.exec_info.queue_remaining;
|
||||
if (q > 0) progressLabel.textContent = `Queue position: ${q}`;
|
||||
}
|
||||
} else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const percent = Math.round((msg.data.value / msg.data.max) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
} else if (msg.type === 'executing') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
progressLabel.textContent = nodeNames[nodeId] || 'Processing...';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForCompletion(promptId) {
|
||||
return new Promise((resolve) => {
|
||||
const checkResolve = () => { clearInterval(pollInterval); resolve(); };
|
||||
resolveCompletion = checkResolve;
|
||||
const pollInterval = setInterval(async () => {
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/check_status/${promptId}`);
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'finished') checkResolve();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
|
||||
else progressLabel.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 2000);
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,76 +282,36 @@
|
||||
const submitter = e.submitter;
|
||||
if (!submitter || submitter.value !== 'preview') return;
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', 'preview');
|
||||
formData.append('client_id', clientId);
|
||||
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.textContent = '0%';
|
||||
progressLabel.textContent = 'Starting...';
|
||||
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = 'Queuing…';
|
||||
try {
|
||||
const response = await fetch(form.getAttribute('action'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
alert('Error: ' + data.error);
|
||||
progressContainer.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
currentPromptId = data.prompt_id;
|
||||
progressLabel.textContent = 'Queued...';
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = 'Queued';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
await waitForCompletion(currentPromptId);
|
||||
await finalizeGeneration(currentPromptId);
|
||||
currentPromptId = null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Request failed');
|
||||
progressContainer.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
async function finalizeGeneration(promptId) {
|
||||
progressLabel.textContent = 'Saving image...';
|
||||
const url = `/checkpoint/{{ ckpt.slug }}/finalize_generation/${promptId}`;
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'preview');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST', body: formData });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
previewImg.src = data.image_url;
|
||||
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) replaceBtn.disabled = false;
|
||||
} else {
|
||||
alert('Save failed: ' + data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Finalize request failed');
|
||||
} finally {
|
||||
progressContainer.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
});
|
||||
|
||||
// Batch: Generate All Characters
|
||||
const allCharacters = [
|
||||
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
|
||||
{% endfor %}
|
||||
];
|
||||
const finalizeBaseUrl = '/checkpoint/{{ ckpt.slug }}/finalize_generation';
|
||||
|
||||
let stopBatch = false;
|
||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
||||
@@ -419,53 +347,36 @@
|
||||
stopAllBtn.classList.remove('d-none');
|
||||
batchProgress.classList.remove('d-none');
|
||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
||||
|
||||
for (let i = 0; i < allCharacters.length; i++) {
|
||||
if (stopBatch) break;
|
||||
const char = allCharacters[i];
|
||||
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
|
||||
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
|
||||
|
||||
const genForm = document.getElementById('generate-form');
|
||||
const fd = new FormData();
|
||||
fd.append('character_slug', char.slug);
|
||||
fd.append('action', 'preview');
|
||||
fd.append('client_id', clientId);
|
||||
|
||||
try {
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.textContent = '0%';
|
||||
progressLabel.textContent = `${char.name}: Starting...`;
|
||||
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLabel.textContent = `${char.name}: Queuing…`;
|
||||
const resp = await fetch(genForm.getAttribute('action'), {
|
||||
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
|
||||
|
||||
currentPromptId = data.prompt_id;
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
progressLabel.textContent = 'Saving image...';
|
||||
const finalFD = new FormData();
|
||||
finalFD.append('action', 'preview');
|
||||
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD });
|
||||
const finalData = await finalResp.json();
|
||||
if (finalData.success) {
|
||||
addToPreviewGallery(finalData.image_url, char.name);
|
||||
previewImg.src = finalData.image_url;
|
||||
currentJobId = data.job_id;
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
addToPreviewGallery(jobResult.result.image_url, char.name);
|
||||
previewImg.src = jobResult.result.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
}
|
||||
currentPromptId = null;
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${char.name}:`, err);
|
||||
currentPromptId = null;
|
||||
} finally {
|
||||
progressContainer.classList.add('d-none');
|
||||
}
|
||||
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
|
||||
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
|
||||
}
|
||||
|
||||
batchBar.style.width = '100%';
|
||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
||||
generateAllBtn.disabled = false;
|
||||
|
||||
Reference in New Issue
Block a user