Files
character-browser/templates/partials/strengths_gallery.html
Aodhan Collins 3c828a170f 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>
2026-03-03 02:32:50 +00:00

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>&#9889; 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>&#127919; Active range:
<strong id="sg-saved-min">{{ sg_weight_min }}</strong>
&ndash;
<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">&#127922;</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">&#128465;</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">
&#128190; 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 &mdash; 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">&#8595; 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 &#8593;</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 %}