Files
character-browser/templates/generator.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

441 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-5">
<div id="progress-container" class="mb-3 d-none">
<label id="progress-label" class="form-label">Generating...</label>
<div class="progress" role="progressbar" aria-label="Generation Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-primary text-white">Generator Settings</div>
<div class="card-body">
<form id="generator-form" action="{{ url_for('generator') }}" method="post">
<!-- Controls bar -->
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
<button type="submit" class="btn btn-primary" id="generate-btn">Generate</button>
<input type="number" id="num-images" value="1" min="1" max="999"
class="form-control form-control-sm" style="width:65px" title="Number of images">
<button type="button" class="btn btn-outline-warning" id="endless-btn">Endless</button>
<button type="button" class="btn btn-danger d-none" id="stop-btn">Stop</button>
<div class="ms-auto form-check mb-0">
<input type="checkbox" class="form-check-input" id="lucky-dip">
<label class="form-check-label" for="lucky-dip">Lucky Dip</label>
</div>
</div>
<!-- Character -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="character" class="form-label mb-0">Character</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-char-btn">Random</button>
</div>
<select class="form-select" id="character" name="character" required>
<option value="" disabled {% if not selected_char %}selected{% endif %}>Select a character...</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_char == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
</div>
<!-- Checkpoint -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="checkpoint" class="form-label mb-0">Checkpoint Model</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button>
</div>
<select class="form-select" id="checkpoint" name="checkpoint" required>
{% for ckpt in checkpoints %}
<option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt }}</option>
{% endfor %}
</select>
<div class="form-text">Listing models from Illustrious/ folder</div>
</div>
<!-- Mix & Match -->
<div class="mb-3">
<label class="form-label">Mix &amp; Match
<small class="text-muted fw-normal ms-1">— first checked per category applies its LoRA</small>
</label>
<div class="accordion" id="mixAccordion">
{% set mix_categories = [
('Actions', 'action', actions, 'action_slugs'),
('Outfits', 'outfit', outfits, 'outfit_slugs'),
('Scenes', 'scene', scenes, 'scene_slugs'),
('Styles', 'style', styles, 'style_slugs'),
('Detailers', 'detailer', detailers, 'detailer_slugs'),
] %}
{% for cat_label, cat_key, cat_items, field_name in mix_categories %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2" type="button"
data-bs-toggle="collapse" data-bs-target="#mix-{{ cat_key }}">
{{ cat_label }}
<span class="badge bg-secondary rounded-pill ms-2" id="badge-{{ cat_key }}">0</span>
<span class="badge bg-light text-secondary border ms-2 px-2 py-1"
style="cursor:pointer;font-size:.7rem;font-weight:normal"
onclick="event.stopPropagation(); randomizeCategory('{{ field_name }}', '{{ cat_key }}')">Random</span>
</button>
</h2>
<div id="mix-{{ cat_key }}" class="accordion-collapse collapse">
<div class="accordion-body p-2">
<input type="text" class="form-control form-control-sm mb-2"
placeholder="Search {{ cat_label | lower }}..."
oninput="filterMixCategory(this, 'mixlist-{{ cat_key }}')">
<div id="mixlist-{{ cat_key }}" style="max-height:220px;overflow-y:auto;">
{% for item in cat_items %}
<label class="mix-item d-flex align-items-center gap-2 px-2 py-1 rounded"
data-name="{{ item.name | lower }}" style="cursor:pointer;">
<input type="checkbox" class="form-check-input flex-shrink-0"
name="{{ field_name }}" value="{{ item.slug }}"
onchange="updateMixBadge('{{ cat_key }}', '{{ field_name }}')">
{% if item.image_path %}
<img src="{{ url_for('static', filename='uploads/' + item.image_path) }}"
class="rounded flex-shrink-0" style="width:32px;height:32px;object-fit:cover">
{% else %}
<span class="rounded bg-light flex-shrink-0 d-inline-flex align-items-center justify-content-center text-muted"
style="width:32px;height:32px;font-size:9px;">N/A</span>
{% endif %}
<span class="small text-truncate">{{ item.name }}</span>
</label>
{% else %}
<p class="text-muted small p-2 mb-0">No {{ cat_label | lower }} found.</p>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Resolution -->
<div class="mb-3">
<label class="form-label">Resolution</label>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-sm btn-secondary preset-btn" data-w="1024" data-h="1024">1:1</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1152" data-h="896">4:3 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="896" data-h="1152">4:3 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1344" data-h="768">16:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1344">16:9 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1280" data-h="800">16:10 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="800" data-h="1280">16:10 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1792" data-h="768">21:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1792">21:9 P</button>
</div>
<div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 small fw-semibold">W</label>
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
value="1024" min="64" max="4096" step="64" style="width:88px">
<span class="text-muted">×</span>
<label class="form-label mb-0 small fw-semibold">H</label>
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
value="1024" min="64" max="4096" step="64" style="width:88px">
</div>
</div>
<!-- Prompt Preview -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">Prompt Preview</label>
<div class="d-flex gap-1">
<button type="button" class="btn btn-sm btn-outline-primary" id="build-preview-btn">Build</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
</div>
</div>
<textarea class="form-control form-control-sm font-monospace" id="prompt-preview"
name="override_prompt" rows="5"
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
<div class="form-text" id="preview-status"></div>
</div>
<!-- Additional prompts -->
<div class="mb-3">
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
<textarea class="form-control" id="positive_prompt" name="positive_prompt" rows="2" placeholder="e.g. sitting in a cafe, drinking coffee, daylight"></textarea>
</div>
<div class="mb-3">
<label for="negative_prompt" class="form-label">Additional Negative Prompt</label>
<textarea class="form-control" id="negative_prompt" name="negative_prompt" rows="2" placeholder="e.g. bad hands, extra digits"></textarea>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card">
<div class="card-header bg-dark text-white">Result</div>
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container">
{% if generated_image %}
<div class="img-container w-100 h-100">
<img src="{{ url_for('static', filename='uploads/' + generated_image) }}" alt="Generated Result" class="img-fluid w-100" id="result-img">
</div>
{% else %}
<div class="text-center text-muted" id="placeholder-text">
<p>Select settings and click Generate</p>
</div>
<div class="img-container w-100 h-100 d-none">
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
</div>
{% endif %}
</div>
<div class="card-footer d-none" id="result-footer">
<small class="text-muted">Saved to character gallery</small>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// --- Filtering ---
function filterMixCategory(input, listId) {
const query = input.value.toLowerCase();
document.querySelectorAll(`#${listId} .mix-item`).forEach(el => {
el.style.display = el.dataset.name.includes(query) ? '' : 'none';
});
}
function updateMixBadge(key, fieldName) {
const count = document.querySelectorAll(`input[name="${fieldName}"]:checked`).length;
const badge = document.getElementById(`badge-${key}`);
badge.textContent = count;
badge.className = count > 0
? 'badge bg-primary rounded-pill ms-2'
: 'badge bg-secondary rounded-pill ms-2';
}
// --- Randomizers (global so inline onclick can call them) ---
function randomizeCategory(fieldName, catKey) {
const cbs = Array.from(document.querySelectorAll(`input[name="${fieldName}"]`));
cbs.forEach(cb => cb.checked = false);
if (cbs.length) cbs[Math.floor(Math.random() * cbs.length)].checked = true;
updateMixBadge(catKey, fieldName);
}
function applyLuckyDip() {
const charOpts = Array.from(document.getElementById('character').options).filter(o => o.value);
if (charOpts.length)
document.getElementById('character').value = charOpts[Math.floor(Math.random() * charOpts.length)].value;
const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
if (ckptOpts.length)
document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
const presets = Array.from(document.querySelectorAll('.preset-btn'));
if (presets.length) presets[Math.floor(Math.random() * presets.length)].click();
[['action_slugs', 'action'], ['outfit_slugs', 'outfit'], ['scene_slugs', 'scene'],
['style_slugs', 'style'], ['detailer_slugs', 'detailer']].forEach(([field, key]) => {
randomizeCategory(field, key);
});
document.getElementById('prompt-preview').value = '';
document.getElementById('preview-status').textContent = '';
}
// --- Resolution presets ---
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.preset-btn').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
});
});
['res-width', 'res-height'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
document.querySelectorAll('.preset-btn').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
});
});
// --- Prompt preview ---
async function buildPromptPreview() {
const charVal = document.getElementById('character').value;
const status = document.getElementById('preview-status');
if (!charVal) { status.textContent = 'Select a character first.'; return; }
status.textContent = 'Building...';
const formData = new FormData(document.getElementById('generator-form'));
try {
const resp = await fetch('/generator/preview_prompt', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) {
status.textContent = 'Error: ' + data.error;
} else {
document.getElementById('prompt-preview').value = data.prompt;
status.textContent = 'Auto-built — edit freely, or Clear to let the server rebuild on generate.';
}
} catch (err) {
status.textContent = 'Request failed.';
}
}
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
document.getElementById('clear-preview-btn').addEventListener('click', () => {
document.getElementById('prompt-preview').value = '';
document.getElementById('preview-status').textContent = '';
});
// --- Main generation logic ---
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('generator-form');
const progressBar = document.getElementById('progress-bar');
const progressCont = document.getElementById('progress-container');
const progressLbl = document.getElementById('progress-label');
const generateBtn = document.getElementById('generate-btn');
const endlessBtn = document.getElementById('endless-btn');
const stopBtn = document.getElementById('stop-btn');
const numInput = document.getElementById('num-images');
const resultImg = document.getElementById('result-img');
const placeholder = document.getElementById('placeholder-text');
const resultFooter = document.getElementById('result-footer');
let currentJobId = null;
let stopRequested = false;
async function waitForJob(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')); }
else if (data.status === 'processing') progressLbl.textContent = 'Generating…';
else progressLbl.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
function setGeneratingState(active) {
generateBtn.disabled = active;
endlessBtn.disabled = active;
stopBtn.classList.toggle('d-none', !active);
if (!active) progressCont.classList.add('d-none');
}
async function runOne(label) {
if (document.getElementById('lucky-dip').checked) applyLuckyDip();
progressCont.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLbl.textContent = label;
const fd = new FormData(form);
const resp = await fetch(form.action, {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
currentJobId = data.job_id;
progressLbl.textContent = 'Queued…';
const jobResult = await waitForJob(currentJobId);
currentJobId = 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) {
const total = endless ? Infinity : (parseInt(numInput.value) || 1);
stopRequested = false;
setGeneratingState(true);
let n = 0;
try {
while (!stopRequested && n < total) {
n++;
const lbl = endless ? `Generating #${n} (endless)...`
: total === 1 ? 'Starting...'
: `Generating ${n} / ${total}...`;
await runOne(lbl);
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
setGeneratingState(false);
}
}
form.addEventListener('submit', (e) => { e.preventDefault(); runLoop(false); });
endlessBtn.addEventListener('click', () => runLoop(true));
stopBtn.addEventListener('click', () => {
stopRequested = true;
progressLbl.textContent = 'Stopping after current image...';
});
document.getElementById('random-char-btn').addEventListener('click', () => {
const opts = Array.from(document.getElementById('character').options).filter(o => o.value);
if (opts.length) {
document.getElementById('character').value = opts[Math.floor(Math.random() * opts.length)].value;
buildPromptPreview();
}
});
document.getElementById('random-ckpt-btn').addEventListener('click', () => {
const opts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
if (opts.length)
document.getElementById('checkpoint').value = opts[Math.floor(Math.random() * opts.length)].value;
});
document.getElementById('character').addEventListener('change', buildPromptPreview);
// Pre-populate from gallery URL params (?action=slug, ?outfit=slug, etc.)
const urlParams = new URLSearchParams(window.location.search);
const catMap = {
action: { field: 'action_slugs', catKey: 'action' },
outfit: { field: 'outfit_slugs', catKey: 'outfit' },
scene: { field: 'scene_slugs', catKey: 'scene' },
style: { field: 'style_slugs', catKey: 'style' },
detailer: { field: 'detailer_slugs', catKey: 'detailer' },
};
let preselected = false;
for (const [param, { field, catKey }] of Object.entries(catMap)) {
const val = urlParams.get(param);
if (!val) continue;
const cb = document.querySelector(`input[name="${field}"][value="${CSS.escape(val)}"]`);
if (cb) {
cb.checked = true;
updateMixBadge(catKey, field);
// Expand the accordion panel
const panel = document.getElementById(`mix-${catKey}`);
if (panel) new bootstrap.Collapse(panel, { toggle: false }).show();
preselected = true;
}
}
if (preselected) buildPromptPreview();
});
</script>
{% endblock %}