Add Checkpoints Gallery with per-checkpoint generation settings

- New Checkpoint model (slug, name, checkpoint_path, data JSON, image_path)
- sync_checkpoints() loads metadata from data/checkpoints/*.json and falls
  back to template defaults for models without a JSON file
- _apply_checkpoint_settings() applies per-checkpoint steps, CFG, sampler,
  base positive/negative prompts, and VAE (with dynamic VAELoader node
  injection for non-integrated VAEs) to the ComfyUI workflow
- Bulk Create from Checkpoints: scans Illustrious/Noob model directories,
  reads matching HTML files, uses LLM to populate metadata, falls back to
  template defaults when no HTML is present
- Gallery index with batch cover generation and WebSocket progress bar
- Detail page showing Generation Settings and Base Prompts cards
- Checkpoints nav link added to layout
- New data/prompts/checkpoint_system.txt LLM system prompt
- Updated README with all current galleries and file structure
- Also includes accumulated action/scene JSON updates, new actions, and
  other template/generator improvements from prior sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-02-26 21:25:23 +00:00
parent 0d7d4d404f
commit 0b8802deb5
334 changed files with 9437 additions and 3772 deletions

401
templates/gallery.html Normal file
View File

@@ -0,0 +1,401 @@
{% extends "layout.html" %}
{% block content %}
<style>
.gallery-card { position: relative; overflow: hidden; border-radius: 8px; background: #1a1a1a; cursor: pointer; }
.gallery-card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; transition: transform 0.2s; }
.gallery-card:hover img { transform: scale(1.04); }
.gallery-card .overlay {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.82));
padding: 28px 8px 8px; opacity: 0; transition: opacity 0.2s;
}
.gallery-card:hover .overlay { opacity: 1; }
.gallery-card .cat-badge {
position: absolute; top: 6px; left: 6px;
font-size: 0.65rem; text-transform: uppercase; letter-spacing: .04em;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
}
@media (min-width: 768px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
@media (min-width: 1200px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
/* Lightbox */
#lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,.92); z-index:9999;
align-items:center; justify-content:center; flex-direction:column; }
#lightbox.active { display:flex; }
#lightbox-img-wrap { position:relative; }
#lightbox-img { max-width:90vw; max-height:80vh; object-fit:contain; border-radius:6px;
box-shadow:0 0 40px rgba(0,0,0,.8); cursor:zoom-in; display:block; }
#lightbox-meta { color:#eee; margin-top:10px; text-align:center; font-size:.85rem; }
#lightbox-hint { color:rgba(255,255,255,.45); font-size:.75rem; margin-top:3px; }
#lightbox-close { position:fixed; top:16px; right:20px; font-size:2rem; color:#fff; cursor:pointer; z-index:10000; line-height:1; }
#lightbox-prompt-btn { position:fixed; bottom:20px; right:20px; z-index:10000; }
/* Prompt modal meta grid */
.meta-grid { display:grid; grid-template-columns:auto 1fr; gap:4px 12px; font-size:.85rem; }
.meta-grid .meta-label { color:#6c757d; white-space:nowrap; font-weight:600; }
.meta-grid .meta-value { font-family:monospace; word-break:break-all; }
.lora-chip { display:inline-flex; align-items:center; gap:4px; background:#f0f0f0;
border-radius:4px; padding:2px 8px; font-size:.8rem; font-family:monospace; margin:2px; }
.lora-chip .lora-strength { color:#6c757d; }
</style>
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Gallery
<span class="text-muted fs-6 fw-normal ms-2">{{ total }} image{{ 's' if total != 1 else '' }}</span>
</h4>
</div>
<!-- Filters -->
<form method="get" action="{{ url_for('gallery') }}" class="mb-3" id="filter-form">
<div class="row g-2 align-items-end">
<!-- Category -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Category</label>
<select name="category" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="all" {% if category == 'all' %}selected{% endif %}>All</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if category == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
{% endfor %}
</select>
</div>
<!-- Item (only when a category is selected) -->
{% if slug_options %}
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Item</label>
<select name="slug" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All items</option>
{% for s, n in slug_options %}
<option value="{{ s }}" {% if slug == s %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
{% else %}
<input type="hidden" name="slug" value="{{ slug }}">
{% endif %}
<!-- Sort -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Sort</label>
<select name="sort" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Newest first</option>
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Oldest first</option>
</select>
</div>
<!-- Per page -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Per page</label>
<select name="per_page" class="form-select form-select-sm" onchange="this.form.submit()">
{% for n in [24, 48, 96] %}
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<!-- Active filter chips -->
<div class="col">
{% if category != 'all' %}
<span class="badge bg-primary me-1">
{{ category | capitalize }}
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
</span>
{% endif %}
{% if slug %}
<span class="badge bg-secondary me-1">
{{ slug }}
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
</span>
{% endif %}
</div>
<input type="hidden" name="page" value="1">
</div>
</form>
<!-- Showing XY of N -->
{% if total > 0 %}
<p class="text-muted small mb-2">
Showing {{ (page - 1) * per_page + 1 }}{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}
</p>
{% endif %}
<!-- Grid -->
{% if images %}
<div class="gallery-grid mb-4">
{% set cat_colors = {
'characters': 'primary',
'actions': 'danger',
'outfits': 'success',
'scenes': 'info',
'styles': 'warning',
'detailers': 'secondary',
} %}
{% for img in images %}
<div class="gallery-card"
data-category="{{ img.category }}"
data-slug="{{ img.slug }}"
data-name="{{ img.item_name }}"
data-path="{{ img.path }}"
data-src="{{ url_for('static', filename='uploads/' + img.path) }}"
onclick="openLightbox(this)">
<img src="{{ url_for('static', filename='uploads/' + img.path) }}"
alt="{{ img.item_name }}"
loading="lazy">
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
{{ img.category[:-1] if img.category.endswith('s') else img.category }}
</span>
<div class="overlay">
<div class="text-white small fw-semibold text-truncate">{{ img.item_name }}</div>
<div class="d-flex gap-1 mt-1">
<button class="btn btn-sm btn-light py-0 px-2 flex-grow-1"
onclick="event.stopPropagation(); showPrompt({{ img.path | tojson }}, {{ img.item_name | tojson }}, {{ img.category | tojson }}, {{ img.slug | tojson }})">
Prompt
</button>
{% if img.category == 'characters' %}
<a href="{{ url_for('detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Open</a>
{% else %}
<a href="{{ url_for('generator') }}?{{ img.category[:-1] }}={{ img.slug }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Generator</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav>
<ul class="pagination pagination-sm flex-wrap justify-content-center">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=page-1) }}"></a>
</li>
{% set window = 3 %}
{% set ns = namespace(last=0) %}
{% for p in range(1, total_pages + 1) %}
{% if p == 1 or p == total_pages or (p >= page - window and p <= page + window) %}
{% if ns.last and p > ns.last + 1 %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=p) }}">{{ p }}</a>
</li>
{% set ns.last = p %}
{% endif %}
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=page+1) }}"></a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<div class="fs-1 mb-2">🖼️</div>
<p>No images found{% if category != 'all' %} in <strong>{{ category }}</strong>{% endif %}{% if slug %} for <strong>{{ slug }}</strong>{% endif %}.</p>
</div>
{% endif %}
<!-- Lightbox -->
<div id="lightbox" onclick="closeLightbox()">
<span id="lightbox-close" onclick="closeLightbox()">×</span>
<div id="lightbox-img-wrap" onclick="event.stopPropagation()">
<img id="lightbox-img" src="" alt="" onclick="openFullSize()">
<div id="lightbox-meta"></div>
<div id="lightbox-hint">Click image to open full size · Esc to close</div>
</div>
<button id="lightbox-prompt-btn" class="btn btn-sm btn-light" onclick="event.stopPropagation(); lightboxShowPrompt()">
View Prompt
</button>
</div>
<!-- Prompt modal -->
<div class="modal fade" id="promptModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="promptModalTitle">Generation Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="promptLoading" class="text-center py-4">
<div class="spinner-border text-secondary"></div>
</div>
<div id="promptContent" class="d-none">
<!-- Prompts -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-semibold mb-0">Positive Prompt</label>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="copyField('promptPositive', this)">Copy</button>
</div>
<textarea id="promptPositive" class="form-control form-control-sm font-monospace" rows="5" readonly></textarea>
</div>
<div class="mb-3" id="negativeRow">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-semibold mb-0">Negative Prompt</label>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="copyField('promptNegative', this)">Copy</button>
</div>
<textarea id="promptNegative" class="form-control form-control-sm font-monospace" rows="2" readonly></textarea>
</div>
<!-- LoRAs -->
<div class="mb-3" id="lorasRow">
<label class="form-label fw-semibold mb-1">LoRAs</label>
<div id="lorasContainer"></div>
</div>
<!-- Generation params -->
<div class="mb-2" id="paramsRow">
<label class="form-label fw-semibold mb-1">Generation Parameters</label>
<div class="meta-grid" id="metaGrid"></div>
</div>
<div id="noMetaMsg" class="d-none">
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
</div>
</div>
</div>
<div class="modal-footer">
<a id="openGeneratorBtn" href="{{ url_for('generator') }}" class="btn btn-primary">Open in Generator</a>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ---- Lightbox state ----
let _lightboxSrc = '';
let _lightboxPath = '';
let _lightboxCategory = '';
let _lightboxSlug = '';
let _lightboxName = '';
function openLightbox(card) {
_lightboxSrc = card.dataset.src;
_lightboxPath = card.dataset.path;
_lightboxCategory = card.dataset.category;
_lightboxSlug = card.dataset.slug;
_lightboxName = card.dataset.name;
document.getElementById('lightbox-img').src = _lightboxSrc;
document.getElementById('lightbox-meta').textContent = _lightboxName + ' · ' + _lightboxCategory;
document.getElementById('lightbox').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
document.body.style.overflow = '';
}
function openFullSize() {
window.open(_lightboxSrc, '_blank');
}
function lightboxShowPrompt() {
closeLightbox();
showPrompt(_lightboxPath, _lightboxName, _lightboxCategory, _lightboxSlug);
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
// ---- Prompt modal ----
let promptModal;
document.addEventListener('DOMContentLoaded', () => {
promptModal = new bootstrap.Modal(document.getElementById('promptModal'));
});
async function showPrompt(imgPath, name, category, slug) {
document.getElementById('promptModalTitle').textContent = name;
document.getElementById('promptContent').classList.add('d-none');
document.getElementById('promptLoading').classList.remove('d-none');
promptModal.show();
try {
const res = await fetch(`/gallery/prompt-data?path=${encodeURIComponent(imgPath)}`);
const data = await res.json();
// Positive prompt
document.getElementById('promptPositive').value = data.positive || '';
// Negative prompt
const neg = data.negative || '';
document.getElementById('promptNegative').value = neg;
document.getElementById('negativeRow').style.display = neg ? '' : 'none';
// LoRAs
const loras = (data.loras || []).filter(l => l.name);
const lorasContainer = document.getElementById('lorasContainer');
lorasContainer.innerHTML = '';
if (loras.length) {
loras.forEach(l => {
const chip = document.createElement('span');
chip.className = 'lora-chip';
// Show only the filename part of the path
const shortName = l.name.split('/').pop().replace('.safetensors', '');
chip.innerHTML = `${shortName} <span class="lora-strength">${Number(l.strength).toFixed(2)}</span>`;
chip.title = l.name;
lorasContainer.appendChild(chip);
});
}
document.getElementById('lorasRow').style.display = loras.length ? '' : 'none';
// Generation params
const params = [
['Checkpoint', data.checkpoint ? data.checkpoint.split('/').pop() : null],
['Seed', data.seed],
['Steps', data.steps],
['CFG', data.cfg],
['Sampler', data.sampler],
['Scheduler', data.scheduler],
];
const hasParams = params.some(([, v]) => v !== null && v !== undefined);
const grid = document.getElementById('metaGrid');
grid.innerHTML = '';
params.forEach(([label, val]) => {
if (val === null || val === undefined) return;
const lEl = document.createElement('span'); lEl.className = 'meta-label'; lEl.textContent = label;
const vEl = document.createElement('span'); vEl.className = 'meta-value'; vEl.textContent = val;
grid.appendChild(lEl); grid.appendChild(vEl);
});
document.getElementById('paramsRow').style.display = hasParams ? '' : 'none';
document.getElementById('noMetaMsg').classList.toggle('d-none',
!!(data.positive || loras.length || hasParams));
// Generator link
const genUrl = category === 'characters'
? `/character/${slug}`
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
document.getElementById('openGeneratorBtn').href = genUrl;
} catch (e) {
document.getElementById('promptPositive').value = 'Error loading metadata.';
} finally {
document.getElementById('promptLoading').classList.add('d-none');
document.getElementById('promptContent').classList.remove('d-none');
}
}
function copyField(id, btn) {
const text = document.getElementById(id).value;
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = orig, 1500);
});
}
</script>
{% endblock %}