- 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>
417 lines
16 KiB
HTML
417 lines
16 KiB
HTML
{% if sg_has_lora %}
|
|
{# -----------------------------------------------------------------------
|
|
Strengths Gallery partial
|
|
Required context variables (set via {% set %} before {% include %}):
|
|
sg_entity — the entity model object
|
|
sg_category — URL category string, e.g. 'outfits', 'characters'
|
|
sg_has_lora — boolean: entity has a non-empty lora_name
|
|
----------------------------------------------------------------------- #}
|
|
{% set sg_lora = sg_entity.data.lora if sg_entity.data.lora else {} %}
|
|
{% set sg_weight_min = sg_lora.get('lora_weight_min', sg_lora.get('lora_weight', 0.0)) %}
|
|
{% set sg_weight_max = sg_lora.get('lora_weight_max', sg_lora.get('lora_weight', 1.0)) %}
|
|
<div class="card mt-4" id="sg-card">
|
|
<div class="card-header d-flex justify-content-between align-items-center"
|
|
style="background: linear-gradient(135deg,#1a1a3e,#2a1a4e); cursor:pointer;"
|
|
onclick="document.getElementById('sg-body').classList.toggle('d-none')">
|
|
<span>⚡ Strengths Gallery
|
|
<small class="text-muted ms-2" style="font-size:.8em;">
|
|
— sweep this LoRA weight with a fixed seed
|
|
</small>
|
|
</span>
|
|
<span id="sg-badge" class="badge bg-secondary">0 images</span>
|
|
</div>
|
|
|
|
<div class="card-body" id="sg-body">
|
|
|
|
{# Saved range indicator #}
|
|
<div id="sg-saved-range" class="alert alert-secondary py-1 px-2 mb-2 d-flex align-items-center gap-2" style="font-size:.85em;">
|
|
<span>🎯 Active range:
|
|
<strong id="sg-saved-min">{{ sg_weight_min }}</strong>
|
|
–
|
|
<strong id="sg-saved-max">{{ sg_weight_max }}</strong>
|
|
</span>
|
|
<span class="text-muted ms-auto fst-italic" id="sg-save-status"></span>
|
|
</div>
|
|
|
|
{# Config row #}
|
|
<div class="row g-2 align-items-end mb-3">
|
|
<div class="col-sm-2">
|
|
<label class="form-label small mb-1">Min Weight</label>
|
|
<input type="number" id="sg-min" class="form-control form-control-sm"
|
|
value="{{ sg_weight_min }}" step="0.05" min="-5" max="5">
|
|
</div>
|
|
<div class="col-sm-2">
|
|
<label class="form-label small mb-1">Max Weight</label>
|
|
<input type="number" id="sg-max" class="form-control form-control-sm"
|
|
value="{{ sg_weight_max }}" step="0.05" min="-5" max="5">
|
|
</div>
|
|
<div class="col-sm-2">
|
|
<label class="form-label small mb-1">Interval</label>
|
|
<input type="number" id="sg-interval" class="form-control form-control-sm"
|
|
value="0.05" step="0.01" min="0.01" max="1.0">
|
|
</div>
|
|
<div class="col-sm-3">
|
|
<label class="form-label small mb-1">Seed</label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="number" id="sg-seed" class="form-control" placeholder="auto">
|
|
<button class="btn btn-outline-secondary" type="button"
|
|
onclick="sgRollSeed()" title="Random seed">🎲</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-1">
|
|
<label class="form-label small mb-1">Count</label>
|
|
<span id="sg-step-count" class="form-control form-control-sm bg-transparent border-0 text-muted ps-0">11</span>
|
|
</div>
|
|
<div class="col-sm-2 d-flex flex-column gap-1">
|
|
<div class="d-flex gap-1">
|
|
<button id="sg-btn-run" class="btn btn-sm btn-primary flex-grow-1"
|
|
onclick="sgStart()">Generate</button>
|
|
<button id="sg-btn-stop" class="btn btn-sm btn-warning d-none"
|
|
onclick="sgStop()">Stop</button>
|
|
<button id="sg-btn-clear" class="btn btn-sm btn-outline-danger"
|
|
onclick="sgClear()" title="Clear results">🗑</button>
|
|
</div>
|
|
<button id="sg-btn-save-range" class="btn btn-sm btn-outline-success w-100"
|
|
onclick="sgSaveRange()" title="Save current Min/Max as the randomisation range for this LoRA">
|
|
💾 Save Range
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# Progress #}
|
|
<div id="sg-progress" class="d-none mb-3">
|
|
<div class="progress" style="height:6px;">
|
|
<div id="sg-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated"
|
|
style="width:0%"></div>
|
|
</div>
|
|
<small class="text-muted" id="sg-progress-label">0 / 0 — weight: —</small>
|
|
</div>
|
|
|
|
{# Results grid #}
|
|
<div id="sg-grid" class="row g-2">
|
|
{# Populated by JS on load and after each generation step #}
|
|
</div>
|
|
|
|
</div>{# /card-body #}
|
|
</div>{# /card #}
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
const SG_CAT = {{ sg_category | tojson }};
|
|
const SG_SLUG = {{ sg_entity.slug | tojson }};
|
|
|
|
let sgRunning = false;
|
|
let sgShouldStop = false;
|
|
let sgQueuedJobs = []; // track all queued job IDs so stop can cancel them
|
|
|
|
// ---- helpers ----
|
|
|
|
function sgRollSeed() {
|
|
document.getElementById('sg-seed').value = Math.floor(Math.random() * 1e15);
|
|
}
|
|
|
|
function sgGetInterval() {
|
|
const v = parseFloat(document.getElementById('sg-interval').value);
|
|
if (isNaN(v) || v < 0.01) return 0.01;
|
|
if (v > 1.0) return 1.0;
|
|
return v;
|
|
}
|
|
|
|
function sgBuildSteps(min, max, interval) {
|
|
if (min > max) return [];
|
|
interval = interval || 0.1;
|
|
const scale = Math.round(1 / interval);
|
|
const steps = [];
|
|
let v = min;
|
|
while (v <= max + interval * 0.5) {
|
|
steps.push(Math.round(v * scale) / scale);
|
|
v = Math.round((v + interval) * scale) / scale;
|
|
}
|
|
return steps;
|
|
}
|
|
|
|
// ---- validation & visual feedback ----
|
|
|
|
function sgValidateBounds() {
|
|
const minEl = document.getElementById('sg-min');
|
|
const maxEl = document.getElementById('sg-max');
|
|
const min = parseFloat(minEl.value);
|
|
const max = parseFloat(maxEl.value);
|
|
const invalid = !isNaN(min) && !isNaN(max) && min > max;
|
|
minEl.classList.toggle('is-invalid', invalid);
|
|
maxEl.classList.toggle('is-invalid', invalid);
|
|
return !invalid;
|
|
}
|
|
|
|
function sgUpdateCount() {
|
|
const valid = sgValidateBounds();
|
|
const min = parseFloat(document.getElementById('sg-min').value) || 0;
|
|
const max = parseFloat(document.getElementById('sg-max').value) || 1;
|
|
const count = valid ? sgBuildSteps(min, max, sgGetInterval()).length : 0;
|
|
document.getElementById('sg-step-count').textContent = count;
|
|
}
|
|
|
|
// Clamp on blur: push the other bound to match rather than block the user mid-type
|
|
document.getElementById('sg-min').addEventListener('change', () => {
|
|
const minEl = document.getElementById('sg-min');
|
|
const maxEl = document.getElementById('sg-max');
|
|
const min = parseFloat(minEl.value), max = parseFloat(maxEl.value);
|
|
if (!isNaN(min) && !isNaN(max) && min > max) maxEl.value = min;
|
|
sgUpdateCount();
|
|
sgHighlightBounds();
|
|
});
|
|
document.getElementById('sg-max').addEventListener('change', () => {
|
|
const minEl = document.getElementById('sg-min');
|
|
const maxEl = document.getElementById('sg-max');
|
|
const min = parseFloat(minEl.value), max = parseFloat(maxEl.value);
|
|
if (!isNaN(min) && !isNaN(max) && max < min) minEl.value = max;
|
|
sgUpdateCount();
|
|
sgHighlightBounds();
|
|
});
|
|
document.getElementById('sg-min').addEventListener('input', () => { sgUpdateCount(); sgHighlightBounds(); });
|
|
document.getElementById('sg-max').addEventListener('input', () => { sgUpdateCount(); sgHighlightBounds(); });
|
|
document.getElementById('sg-interval').addEventListener('input', sgUpdateCount);
|
|
|
|
// ---- highlight matching min/max buttons ----
|
|
|
|
function sgHighlightBounds() {
|
|
const currentMin = parseFloat(document.getElementById('sg-min').value);
|
|
const currentMax = parseFloat(document.getElementById('sg-max').value);
|
|
document.querySelectorAll('#sg-grid .sg-thumb').forEach(thumb => {
|
|
const sv = parseFloat(thumb.dataset.sgStrength);
|
|
const minBtn = thumb.querySelector('[data-sg-role="min-btn"]');
|
|
const maxBtn = thumb.querySelector('[data-sg-role="max-btn"]');
|
|
if (minBtn) {
|
|
const active = sv === currentMin;
|
|
minBtn.classList.toggle('btn-primary', active);
|
|
minBtn.classList.toggle('btn-outline-primary', !active);
|
|
}
|
|
if (maxBtn) {
|
|
const active = sv === currentMax;
|
|
maxBtn.classList.toggle('btn-warning', active);
|
|
maxBtn.classList.toggle('btn-outline-warning', !active);
|
|
}
|
|
});
|
|
}
|
|
|
|
function sgUpdateBadge() {
|
|
const count = document.querySelectorAll('#sg-grid .sg-thumb').length;
|
|
document.getElementById('sg-badge').textContent = count + ' image' + (count !== 1 ? 's' : '');
|
|
}
|
|
|
|
function sgSetMin(v) {
|
|
const maxEl = document.getElementById('sg-max');
|
|
document.getElementById('sg-min').value = v;
|
|
if (v > parseFloat(maxEl.value)) maxEl.value = v;
|
|
sgUpdateCount();
|
|
sgHighlightBounds();
|
|
}
|
|
|
|
function sgSetMax(v) {
|
|
const minEl = document.getElementById('sg-min');
|
|
document.getElementById('sg-max').value = v;
|
|
if (v < parseFloat(minEl.value)) minEl.value = v;
|
|
sgUpdateCount();
|
|
sgHighlightBounds();
|
|
}
|
|
|
|
function sgAddImage(imageUrl, strengthValue) {
|
|
const grid = document.getElementById('sg-grid');
|
|
const col = document.createElement('div');
|
|
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
|
col.innerHTML = `
|
|
<div class="card h-100 sg-thumb" data-sg-strength="${strengthValue}">
|
|
<img src="${imageUrl}" class="card-img-top" style="object-fit:cover;height:160px;cursor:zoom-in;"
|
|
loading="lazy" onclick="window.open(this.src,'_blank')">
|
|
<div class="card-footer py-1 px-1">
|
|
<div class="text-center mb-1"><span class="badge bg-secondary">${strengthValue}</span></div>
|
|
<div class="d-flex gap-1">
|
|
<button class="btn btn-outline-primary btn-sm flex-grow-1 py-0" data-sg-role="min-btn"
|
|
style="font-size:.7em;" onclick="sgSetMin(${strengthValue})" title="Set as Min weight">↓ Min</button>
|
|
<button class="btn btn-outline-warning btn-sm flex-grow-1 py-0" data-sg-role="max-btn"
|
|
style="font-size:.7em;" onclick="sgSetMax(${strengthValue})" title="Set as Max weight">Max ↑</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
grid.appendChild(col);
|
|
sgUpdateBadge();
|
|
sgHighlightBounds();
|
|
}
|
|
|
|
// ---- Job queue wait ----
|
|
|
|
function sgWaitForJob(jobId) {
|
|
return new Promise((resolve, reject) => {
|
|
const poll = setInterval(async () => {
|
|
try {
|
|
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
const data = await resp.json();
|
|
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
else if (data.status === 'failed' || data.status === 'removed') {
|
|
clearInterval(poll); reject(new Error(data.error || 'Job failed'));
|
|
}
|
|
} catch (err) { console.error('[Strengths] poll error:', err); }
|
|
}, 1500);
|
|
});
|
|
}
|
|
|
|
// ---- main flow ----
|
|
|
|
async function sgClearImages() {
|
|
await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/clear`, { method: 'POST' });
|
|
document.getElementById('sg-grid').innerHTML = '';
|
|
sgUpdateBadge();
|
|
}
|
|
|
|
async function sgStart() {
|
|
if (sgRunning) return;
|
|
|
|
const min = parseFloat(document.getElementById('sg-min').value);
|
|
const max = parseFloat(document.getElementById('sg-max').value);
|
|
let seed = parseInt(document.getElementById('sg-seed').value);
|
|
if (isNaN(seed)) {
|
|
seed = Math.floor(Math.random() * 1e15);
|
|
document.getElementById('sg-seed').value = seed;
|
|
}
|
|
|
|
if (!sgValidateBounds()) return;
|
|
const steps = sgBuildSteps(min, max, sgGetInterval());
|
|
if (!steps.length) return;
|
|
|
|
await sgClearImages();
|
|
|
|
sgRunning = true;
|
|
sgShouldStop = false;
|
|
sgQueuedJobs = [];
|
|
document.getElementById('sg-btn-run').classList.add('d-none');
|
|
document.getElementById('sg-btn-stop').classList.remove('d-none');
|
|
document.getElementById('sg-progress').classList.remove('d-none');
|
|
|
|
const charSelect = document.getElementById('character_select');
|
|
const charSlug = charSelect ? charSelect.value : '';
|
|
|
|
// Phase 1: Queue all steps upfront so generation continues even if the page is navigated away
|
|
document.getElementById('sg-progress-bar').style.width = '100%';
|
|
document.getElementById('sg-progress-bar').classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
for (let i = 0; i < steps.length; i++) {
|
|
if (sgShouldStop) break;
|
|
const sv = steps[i];
|
|
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`, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: formData,
|
|
});
|
|
const queueData = await queueResp.json();
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Phase 2: Poll all jobs concurrently; show results as each finishes
|
|
document.getElementById('sg-progress-bar').classList.remove('progress-bar-striped', 'progress-bar-animated');
|
|
document.getElementById('sg-progress-bar').style.width = '0%';
|
|
|
|
let completed = 0;
|
|
await Promise.all(sgQueuedJobs.map(async ({ jobId, sv }) => {
|
|
try {
|
|
const jobResult = await sgWaitForJob(jobId);
|
|
if (jobResult.result && jobResult.result.image_url) {
|
|
sgAddImage(jobResult.result.image_url, sv);
|
|
}
|
|
} catch (err) {
|
|
console.error('[Strengths] job error:', sv, err);
|
|
}
|
|
completed++;
|
|
const pct = Math.round((completed / sgQueuedJobs.length) * 100);
|
|
document.getElementById('sg-progress-bar').style.width = pct + '%';
|
|
document.getElementById('sg-progress-label').textContent =
|
|
`${completed} / ${sgQueuedJobs.length} done`;
|
|
}));
|
|
|
|
document.getElementById('sg-progress-bar').style.width = '100%';
|
|
document.getElementById('sg-progress-label').textContent =
|
|
`Done \u2014 ${sgQueuedJobs.length} images generated`;
|
|
setTimeout(() => document.getElementById('sg-progress').classList.add('d-none'), 3000);
|
|
|
|
document.getElementById('sg-btn-stop').classList.add('d-none');
|
|
document.getElementById('sg-btn-run').classList.remove('d-none');
|
|
sgRunning = false;
|
|
}
|
|
|
|
function sgStop() {
|
|
sgShouldStop = true;
|
|
// Cancel any pending (not yet processing) queued jobs
|
|
sgQueuedJobs.forEach(({ jobId }) => {
|
|
fetch(`/api/queue/${jobId}/remove`, { method: 'POST' }).catch(() => {});
|
|
});
|
|
document.getElementById('sg-btn-stop').classList.add('d-none');
|
|
document.getElementById('sg-btn-run').classList.remove('d-none');
|
|
}
|
|
|
|
async function sgClear() {
|
|
if (!confirm('Clear all Strengths Gallery images for this item?')) return;
|
|
await sgClearImages();
|
|
}
|
|
|
|
async function sgSaveRange() {
|
|
const min = parseFloat(document.getElementById('sg-min').value);
|
|
const max = parseFloat(document.getElementById('sg-max').value);
|
|
if (isNaN(min) || isNaN(max)) {
|
|
alert('Set valid Min and Max values first.');
|
|
return;
|
|
}
|
|
const statusEl = document.getElementById('sg-save-status');
|
|
statusEl.textContent = 'Saving…';
|
|
try {
|
|
const body = new URLSearchParams({ min_weight: Math.min(min, max), max_weight: Math.max(min, max) });
|
|
const resp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/save_range`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
document.getElementById('sg-saved-min').textContent = data.lora_weight_min;
|
|
document.getElementById('sg-saved-max').textContent = data.lora_weight_max;
|
|
statusEl.textContent = '✓ Saved';
|
|
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
|
} else {
|
|
statusEl.textContent = '✗ ' + (data.error || 'Error');
|
|
}
|
|
} catch (e) {
|
|
statusEl.textContent = '✗ Network error';
|
|
}
|
|
}
|
|
|
|
// Expose functions to inline onclick handlers
|
|
window.sgStart = sgStart;
|
|
window.sgStop = sgStop;
|
|
window.sgClear = sgClear;
|
|
window.sgSaveRange = sgSaveRange;
|
|
window.sgRollSeed = sgRollSeed;
|
|
window.sgSetMin = sgSetMin;
|
|
window.sgSetMax = sgSetMax;
|
|
|
|
// ---- Load existing images on page load ----
|
|
|
|
fetch(`/strengths/${SG_CAT}/${SG_SLUG}/list`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
(data.images || []).forEach(img => sgAddImage(img.url, img.strength));
|
|
sgUpdateCount();
|
|
sgHighlightBounds();
|
|
})
|
|
.catch(() => sgUpdateCount());
|
|
|
|
})();
|
|
</script>
|
|
{% endif %}
|