Files
character-browser/templates/gallery.html
Aodhan Collins 0b8802deb5 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>
2026-02-26 21:25:23 +00:00

402 lines
16 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 %}
<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 %}