Add extra prompts, endless generation, random character default, and small fixes

- Add extra positive/negative prompt textareas to all 9 detail pages with session persistence
- Add Endless generation button to all detail pages (continuous preview generation until stopped)
- Default character selector to "Random Character" on all secondary detail pages
- Fix queue clear endpoint (remove spurious auth check)
- Refactor app.py into routes/ and services/ modules
- Update CLAUDE.md with new architecture documentation
- Various data file updates and cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-13 02:07:16 +00:00
parent 1b8a798c31
commit 5e4348ebc1
170 changed files with 17367 additions and 9781 deletions

View File

@@ -29,17 +29,6 @@
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
{% macro selection_checkbox(section, key, label, value) %}
<input class="form-check-input me-1" type="checkbox" name="include_field" value="{{ section }}::{{ key }}"
{% if preferences is not none %}
@@ -54,7 +43,7 @@
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if action.image_path %}
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid" data-preview-path="{{ action.image_path }}">
{% else %}
@@ -62,20 +51,12 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_action_image', slug=action.slug) }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="image" class="form-label">Update Image</label>
<input class="form-control" type="file" id="image" name="image" required>
</div>
<button type="submit" class="btn btn-primary w-100 mb-2">Upload</button>
</form>
{# Character Selector #}
<div class="mb-3">
<label for="character_select" class="form-label">Preview with Character</label>
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
<option value="">-- No Character (Action Only) --</option>
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
<option value="__random__" {% if selected_character == '__random__' or not selected_character %}selected{% endif %}>🎲 Random Character</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_character == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
@@ -83,8 +64,26 @@
<div class="form-text">Select a character to preview this action on their model.</div>
</div>
{# Additional Prompts #}
<div class="mb-2">
<label for="extra_positive" class="form-label">Additional Positive</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality" form="generate-form">{{ extra_positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality" form="generate-form">{{ extra_negative or '' }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" form="generate-form" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form" data-requires="comfyui">Generate Preview</button>
<button type="button" class="btn btn-outline-info" id="endless-btn" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" class="btn btn-danger d-none" id="endless-stop-btn" onclick="window._endlessStop()">Stop Endless</button>
<small class="text-muted d-none" id="endless-counter"></small>
<button type="submit" form="generate-form" formaction="{{ url_for('save_action_defaults', slug=action.slug) }}" class="btn btn-sm btn-outline-secondary mt-2">Save as Default Selection</button>
</div>
</div>
@@ -106,7 +105,7 @@
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
@@ -121,10 +120,8 @@
{% if 'special::tags' in preferences %}checked{% endif %}
{% elif action.default_fields is not none %}
{% if 'special::tags' in action.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label text-white small" for="includeTags">Include</label>
<label class="form-check-label text-white small {% if action.default_fields is not none and 'special::tags' in action.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
</div>
</div>
<div class="card-body">
@@ -148,6 +145,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('transfer_resource', category='actions', slug=action.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -161,6 +159,11 @@
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
{% if action.data.get('lora', {}).get('lora_name', '') != '' %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="strengths-tab" data-bs-toggle="tab" data-bs-target="#strengths-pane" type="button" role="tab">Strengths</button>
</li>
{% endif %}
</ul>
<div class="tab-content" id="detailTabContent">
@@ -256,7 +259,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">{{ existing_previews|length }} preview(s)</span>
<div class="d-flex gap-2">
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm">Generate All Characters</button>
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm" data-requires="comfyui">Generate All Characters</button>
<button type="button" id="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
@@ -270,10 +273,8 @@
{% for img in existing_previews %}
<div class="col">
<img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded"
class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
@@ -281,14 +282,18 @@
{% endfor %}
</div>
</div>
{% set sg_has_lora = action.data.get('lora', {}).get('lora_name', '') != '' %}
{% if sg_has_lora %}
<div class="tab-pane fade" id="strengths-pane" role="tabpanel">
{% set sg_entity = action %}
{% set sg_category = 'actions' %}
{% include 'partials/strengths_gallery.html' %}
</div>
{% endif %}
</div>
</div>
</div>
{% set sg_entity = action %}
{% set sg_category = 'actions' %}
{% set sg_has_lora = action.data.get('lora', {}).get('lora_name', '') != '' %}
{% include 'partials/strengths_gallery.html' %}
{% endblock %}
{% block scripts %}
@@ -361,10 +366,19 @@
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
@@ -382,16 +396,23 @@
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
col.innerHTML = `<div class="position-relative preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
@@ -449,10 +470,9 @@
});
initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
});
</script>
{% endblock %}

View File

@@ -4,8 +4,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Action Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new action entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
@@ -20,37 +20,10 @@
</div>
</div>
<!-- Batch Progress Bar -->
<div id="batch-progress-container" class="card mb-4 d-none">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 id="batch-status-text" class="mb-0">Batch Generating Actions...</h5>
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
</div>
<div class="mb-3">
<small class="text-muted">Overall Batch Progress</small>
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<small id="current-item-name" class="text-muted mb-1"></small>
<small id="current-step-progress" class="text-muted mb-1"></small>
</div>
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for action in actions %}
<div class="col" id="card-{{ action.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/action/{{ action.slug }}'">
<div class="card h-100 character-card {% if request.args.get('highlight') == action.slug %}border-success border-3 highlight-card{% endif %}" onclick="window.location.href='/action/{{ action.slug }}'">
<div class="img-container">
{% if action.image_path %}
<img id="img-{{ action.slug }}" src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}">
@@ -90,15 +63,29 @@
{% endblock %}
{% block scripts %}
<style>
.highlight-card {
animation: highlight-pulse 2s ease-in-out 3;
box-shadow: 0 0 20px rgba(25, 135, 84, 0.5) !important;
}
@keyframes highlight-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(25, 135, 84, 0.5); }
50% { box-shadow: 0 0 30px rgba(25, 135, 84, 0.8); }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const progressBar = document.getElementById('batch-progress-bar');
const taskProgressBar = document.getElementById('task-progress-bar');
const container = document.getElementById('batch-progress-container');
const statusText = document.getElementById('batch-status-text');
const nodeStatus = document.getElementById('batch-node-status');
const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress');
@@ -129,17 +116,12 @@
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
// Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const item of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/action/${item.slug}/generate`, {
method: 'POST',
@@ -154,12 +136,6 @@
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
@@ -175,23 +151,11 @@
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%';
progressBar.textContent = '100%';
statusText.textContent = 'Batch Action Generation Complete!';
itemNameText.textContent = '';
nodeStatus.textContent = 'Done';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => container.classList.add('d-none'), 5000);
alert(`Batch generation complete! ${jobs.length} action images processed.`);
}
batchBtn.addEventListener('click', async () => {

View File

@@ -29,23 +29,11 @@
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if ckpt.image_path %}
<img src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}" class="img-fluid"
data-preview-path="{{ ckpt.image_path }}">
@@ -56,29 +44,37 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_checkpoint_image', slug=ckpt.slug) }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="image" class="form-label text-muted small">Update Cover Image</label>
<input class="form-control form-control-sm" type="file" id="image" name="image" required>
</div>
<button type="submit" class="btn btn-sm btn-outline-primary w-100 mb-2">Upload</button>
</form>
<hr>
<div class="mb-3">
<label for="character_select" class="form-label">Preview with Character</label>
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
<option value="">-- No Character (Generic) --</option>
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
<option value="__random__" {% if selected_character == '__random__' or not selected_character %}selected{% endif %}>🎲 Random Character</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_character == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
</div>
{# Additional Prompts #}
<div class="mb-2">
<label for="extra_positive" class="form-label">Additional Positive</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality" form="generate-form">{{ extra_positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality" form="generate-form">{{ extra_negative or '' }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" form="generate-form" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form" data-requires="comfyui">Generate Preview</button>
<button type="button" id="endless-btn" class="btn btn-outline-success" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" id="endless-stop-btn" class="btn btn-danger d-none" onclick="window._endlessStop()">Stop</button>
<small id="endless-counter" class="text-muted d-none"></small>
</div>
</div>
</div>
@@ -100,8 +96,7 @@
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
@@ -206,7 +201,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">{{ existing_previews|length }} preview(s)</span>
<div class="d-flex gap-2">
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm">Generate All Characters</button>
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm" data-requires="comfyui">Generate All Characters</button>
<button type="button" id="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
@@ -220,11 +215,9 @@
{% for img in existing_previews %}
<div class="col">
<img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded"
class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="{{ img }}"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -297,6 +290,7 @@
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
@@ -320,16 +314,23 @@
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
col.innerHTML = `<div class="position-relative preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
</div>`;
gallery.insertBefore(col, gallery.firstChild);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
@@ -385,12 +386,18 @@
batchLabel.textContent = 'Stopping after current submissions...';
});
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, 'Endless');
}
};
// JSON Editor
initJsonEditor('{{ url_for("save_checkpoint_json", slug=ckpt.slug) }}');
});
function showImage(src) {
if (src) document.getElementById('modalImage').src = src;
}
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
});
</script>
{% endblock %}

View File

@@ -4,8 +4,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Checkpoint Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new checkpoint entries from all checkpoint files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
@@ -19,31 +19,6 @@
</div>
</div>
<!-- Batch Progress Bar -->
<div id="batch-progress-container" class="card mb-4 d-none">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 id="batch-status-text" class="mb-0">Batch Generating Checkpoints...</h5>
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
</div>
<div class="mb-3">
<small class="text-muted">Overall Batch Progress</small>
<div class="progress" style="height: 10px;">
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<small id="current-ckpt-name" class="text-muted mb-1"></small>
<small id="current-step-progress" class="text-muted mb-1"></small>
</div>
<div class="progress" style="height: 20px;">
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for ckpt in checkpoints %}
<div class="col" id="card-{{ ckpt.slug }}">
@@ -76,11 +51,6 @@
document.addEventListener('DOMContentLoaded', () => {
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const progressBar = document.getElementById('batch-progress-bar');
const taskProgressBar = document.getElementById('task-progress-bar');
const container = document.getElementById('batch-progress-container');
const statusText = document.getElementById('batch-status-text');
const nodeStatus = document.getElementById('batch-node-status');
const ckptNameText = document.getElementById('current-ckpt-name');
const stepProgressText = document.getElementById('current-step-progress');
@@ -113,17 +83,12 @@
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
// Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const ckpt of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
method: 'POST',
@@ -138,12 +103,6 @@
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
@@ -159,24 +118,11 @@
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%';
progressBar.textContent = '100%';
statusText.textContent = 'Batch Checkpoint Generation Complete!';
ckptNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => container.classList.add('d-none'), 5000);
alert(`Batch generation complete! ${jobs.length} checkpoint images processed.`);
}
batchBtn.addEventListener('click', async () => {

View File

@@ -1,21 +1,10 @@
{% extends "layout.html" %}
{% block content %}
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if character.image_path %}
<img src="{{ url_for('static', filename='uploads/' + character.image_path) }}" alt="{{ character.name }}" class="img-fluid" data-preview-path="{{ character.image_path }}">
{% else %}
@@ -23,15 +12,26 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_image', slug=character.slug) }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="image" class="form-label">Update Image</label>
<input class="form-control" type="file" id="image" name="image" required>
</div>
<button type="submit" class="btn btn-primary w-100 mb-2">Upload</button>
</form>
{# Additional Prompts #}
<div class="mb-2">
<label for="extra_positive" class="form-label">Additional Positive</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality" form="generate-form">{{ extra_positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality" form="generate-form">{{ extra_negative or '' }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" form="generate-form" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form" data-requires="comfyui">Generate Preview</button>
<button type="button" class="btn btn-outline-info" id="endless-btn" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" class="btn btn-danger d-none" id="endless-stop-btn" onclick="window._endlessStop()">Stop Endless</button>
<small class="text-muted d-none" id="endless-counter"></small>
<button type="submit" form="generate-form" formaction="{{ url_for('save_defaults', slug=character.slug) }}" class="btn btn-sm btn-outline-secondary mt-2">Save as Default Selection</button>
</div>
</div>
@@ -53,7 +53,7 @@
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
@@ -63,15 +63,13 @@
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<span>Tags</span>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
{% if preferences is not none %}
{% if 'special::tags' in preferences %}checked{% endif %}
{% elif character.default_fields is not none %}
{% if 'special::tags' in character.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label text-white small" for="includeTags">Include</label>
<label class="form-check-label text-white small {% if character.default_fields is not none and 'special::tags' in character.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
</div>
</div>
<div class="card-body">
@@ -86,37 +84,91 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">{{ character.name }}</h1>
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
<div class="btn-group" role="group">
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
<a href="{{ url_for('transfer_character', slug=character.slug) }}" class="btn btn-sm btn-warning text-decoration-none">
<i class="bi bi-arrow-left-right"></i> Transfer
</a>
</div>
</div>
<a href="/" class="btn btn-outline-secondary">Back to Library</a>
</div>
<!-- Outfit Switcher -->
<ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings-pane" type="button" role="tab">Settings</button>
</li>
{% if character.data.get('lora', {}).get('lora_name', '') != '' %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="strengths-tab" data-bs-toggle="tab" data-bs-target="#strengths-pane" type="button" role="tab">Strengths</button>
</li>
{% endif %}
</ul>
<div class="tab-content" id="detailTabContent">
<div class="tab-pane fade show active" id="settings-pane" role="tabpanel">
<!-- Outfit Management Section -->
{% set outfits = character.get_available_outfits() %}
{% if outfits|length > 1 %}
<div class="card mb-4 border-primary">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<span><i class="bi bi-shirt"></i> Active Outfit</span>
<span class="badge bg-light text-primary">{{ character.active_outfit or 'default' }}</span>
<span><i class="bi bi-shirt"></i> Wardrobe</span>
<span class="badge bg-light text-primary">{{ outfits|length }} outfit(s)</span>
</div>
<div class="card-body">
<form action="{{ url_for('switch_outfit', slug=character.slug) }}" method="post" class="row g-2">
<div class="col-auto flex-grow-1">
<select name="outfit" class="form-select" id="outfit-select">
<!-- Active Outfit Selector -->
<form action="{{ url_for('switch_outfit', slug=character.slug) }}" method="post" class="mb-3">
<label class="form-label">Active Outfit</label>
<div class="input-group">
<select name="outfit" class="form-select">
{% for outfit in outfits %}
<option value="{{ outfit }}" {% if outfit == character.active_outfit %}selected{% endif %}>
{{ outfit }}
<option value="{{ outfit.outfit_id }}" {% if outfit.outfit_id == character.active_outfit %}selected{% endif %}>
{{ outfit.name }}
{% if outfit.source == 'assigned' %}(External){% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">Switch</button>
</div>
</form>
<!-- Assign New Outfit -->
<form action="{{ url_for('assign_outfit', slug=character.slug) }}" method="post">
<label class="form-label">Add Outfit to Wardrobe</label>
<div class="input-group">
<select name="outfit_id" class="form-select">
<option value="">-- Select Outfit --</option>
{% for outfit in all_outfits %}
{% if outfit.outfit_id not in character.assigned_outfit_ids and outfit.outfit_id != 'default' %}
<option value="{{ outfit.outfit_id }}">{{ outfit.name }}</option>
{% endif %}
{% endfor %}
</select>
<button type="submit" class="btn btn-success">Assign</button>
</div>
</form>
<!-- Currently Assigned Outfits -->
{% if character.assigned_outfit_ids %}
<div class="mt-3">
<label class="form-label">Assigned External Outfits</label>
<div class="d-flex flex-wrap gap-2">
{% for outfit_id in character.assigned_outfit_ids %}
{% set outfit = get_outfit_by_id(outfit_id) %}
{% if outfit %}
<span class="badge bg-secondary d-flex align-items-center">
{{ outfit.name }}
<form action="{{ url_for('unassign_outfit', slug=character.slug, outfit_id=outfit_id) }}" method="post" class="d-inline ms-1">
<button type="submit" class="btn btn-sm btn-link text-white p-0" style="text-decoration: none;" onclick="return confirm('Unassign this outfit?')">&times;</button>
</form>
</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<form id="generate-form" action="{{ url_for('generate_image', slug=character.slug) }}" method="post">
{% for section, details in character.data.items() %}
@@ -138,18 +190,20 @@
<div class="card-body">
<dl class="row mb-0">
{% for key, value in active_wardrobe.items() %}
<dt class="col-sm-4 text-capitalize">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="wardrobe::{{ key }}"
{% set is_default = character.default_fields is not none and ('wardrobe::' ~ key) in character.default_fields %}
<dt class="col-sm-4 text-capitalize {% if is_default %}text-accent{% endif %}">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="wardrobe::{{ key }}"
{% if preferences is not none %}
{% if 'wardrobe::' + key in preferences %}checked{% endif %}
{% elif character.default_fields is not none %}
{% if 'wardrobe::' + key in character.default_fields %}checked{% endif %}
{% if is_default %}checked{% endif %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
{% if is_default %}<span class="badge bg-primary ms-1" style="font-size: 0.55rem; vertical-align: middle;">DEF</span>{% endif %}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
<dd class="col-sm-8 {% if is_default %}text-accent{% endif %}">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
@@ -160,33 +214,37 @@
<div class="card-body">
<dl class="row mb-0">
{% if section == 'identity' %}
<dt class="col-sm-4 text-capitalize">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="special::name"
{% set is_name_default = character.default_fields is not none and 'special::name' in character.default_fields %}
<dt class="col-sm-4 text-capitalize {% if is_name_default %}text-accent{% endif %}">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="special::name"
{% if preferences is not none %}
{% if 'special::name' in preferences %}checked{% endif %}
{% elif character.default_fields is not none %}
{% if 'special::name' in character.default_fields %}checked{% endif %}
{% if is_name_default %}checked{% endif %}
{% else %}
checked
{% endif %}>
Character ID
{% if is_name_default %}<span class="badge bg-primary ms-1" style="font-size: 0.55rem; vertical-align: middle;">DEF</span>{% endif %}
</dt>
<dd class="col-sm-8">{{ character.character_id }}</dd>
<dd class="col-sm-8 {% if is_name_default %}text-accent{% endif %}">{{ character.character_id }}</dd>
{% endif %}
{% for key, value in details.items() %}
<dt class="col-sm-4 text-capitalize">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="{{ section }}::{{ key }}"
{% set is_default = character.default_fields is not none and (section ~ '::' ~ key) in character.default_fields %}
<dt class="col-sm-4 text-capitalize {% if is_default %}text-accent{% endif %}">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="{{ section }}::{{ key }}"
{% if preferences is not none %}
{% if section + '::' + key in preferences %}checked{% endif %}
{% elif character.default_fields is not none %}
{% if section + '::' + key in character.default_fields %}checked{% endif %}
{% if is_default %}checked{% endif %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
{% if is_default %}<span class="badge bg-primary ms-1" style="font-size: 0.55rem; vertical-align: middle;">DEF</span>{% endif %}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
<dd class="col-sm-8 {% if is_default %}text-accent{% endif %}">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
@@ -194,13 +252,20 @@
{% endif %}
{% endfor %}
</form>
</div>{# /settings-pane #}
{% set sg_has_lora = character.data.get('lora', {}).get('lora_name', '') != '' %}
{% if sg_has_lora %}
<div class="tab-pane fade" id="strengths-pane" role="tabpanel">
{% set sg_entity = character %}
{% set sg_category = 'characters' %}
{% include 'partials/strengths_gallery.html' %}
</div>
{% endif %}
</div>{# /tab-content #}
</div>
</div>
{% set sg_entity = character %}
{% set sg_category = 'characters' %}
{% set sg_has_lora = character.data.get('lora', {}).get('lora_name', '') != '' %}
{% include 'partials/strengths_gallery.html' %}
{% endblock %}
{% block scripts %}
@@ -286,6 +351,7 @@
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
}
updateSeedFromResult(jobResult.result);
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
@@ -294,10 +360,13 @@
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
});
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
}
};
});
</script>
{% endblock %}

View File

@@ -29,17 +29,6 @@
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
{% macro selection_checkbox(section, key, label, value) %}
<input class="form-check-input me-1" type="checkbox" name="include_field" value="{{ section }}::{{ key }}"
{% if preferences is not none %}
@@ -54,7 +43,7 @@
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if detailer.image_path %}
<img src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}" class="img-fluid" data-preview-path="{{ detailer.image_path }}">
{% else %}
@@ -64,22 +53,12 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_detailer_image', slug=detailer.slug) }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="image" class="form-label text-muted small">Update Cover Image</label>
<input class="form-control form-control-sm" type="file" id="image" name="image" required>
</div>
<button type="submit" class="btn btn-sm btn-outline-primary w-100 mb-2">Upload</button>
</form>
<hr>
{# Character Selector #}
<div class="mb-3">
<label for="character_select" class="form-label">Preview with Character</label>
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
<option value="">-- No Character (Detailer Only) --</option>
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
<option value="__random__" {% if selected_character == '__random__' or not selected_character %}selected{% endif %}>🎲 Random Character</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_character == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
@@ -108,7 +87,15 @@
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" form="generate-form" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form" data-requires="comfyui">Generate Preview</button>
<button type="button" class="btn btn-outline-info" id="endless-btn" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" class="btn btn-danger d-none" id="endless-stop-btn" onclick="window._endlessStop()">Stop Endless</button>
<small class="text-muted d-none" id="endless-counter"></small>
<button type="submit" form="generate-form" formaction="{{ url_for('save_detailer_defaults', slug=detailer.slug) }}" class="btn btn-sm btn-outline-secondary mt-2">Save Selection as Default</button>
</div>
</div>
@@ -130,7 +117,7 @@
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
@@ -147,6 +134,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('transfer_resource', category='detailers', slug=detailer.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -160,6 +148,11 @@
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
{% if detailer.data.get('lora', {}).get('lora_name', '') != '' %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="strengths-tab" data-bs-toggle="tab" data-bs-target="#strengths-pane" type="button" role="tab">Strengths</button>
</li>
{% endif %}
</ul>
<div class="tab-content" id="detailTabContent">
@@ -225,7 +218,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">{{ existing_previews|length }} preview(s)</span>
<div class="d-flex gap-2">
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm">Generate All Characters</button>
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm" data-requires="comfyui">Generate All Characters</button>
<button type="button" id="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
@@ -241,8 +234,8 @@
<img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
data-preview-path="{{ img }}">
</div>
{% else %}
@@ -250,14 +243,18 @@
{% endfor %}
</div>
</div>
{% set sg_has_lora = detailer.data.get('lora', {}).get('lora_name', '') != '' %}
{% if sg_has_lora %}
<div class="tab-pane fade" id="strengths-pane" role="tabpanel">
{% set sg_entity = detailer %}
{% set sg_category = 'detailers' %}
{% include 'partials/strengths_gallery.html' %}
</div>
{% endif %}
</div>
</div>
</div>
{% set sg_entity = detailer %}
{% set sg_category = 'detailers' %}
{% set sg_has_lora = detailer.data.get('lora', {}).get('lora_name', '') != '' %}
{% include 'partials/strengths_gallery.html' %}
{% endblock %}
{% block scripts %}
@@ -331,10 +328,19 @@
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
@@ -355,8 +361,8 @@
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
@@ -424,8 +430,6 @@
initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}
</script>
{% endblock %}

View File

@@ -4,8 +4,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Detailer Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new detailer entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
@@ -20,37 +20,10 @@
</div>
</div>
<!-- Batch Progress Bar -->
<div id="batch-progress-container" class="card mb-4 d-none">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 id="batch-status-text" class="mb-0">Batch Generating Detailers...</h5>
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
</div>
<div class="mb-3">
<small class="text-muted">Overall Batch Progress</small>
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<small id="current-detailer-name" class="text-muted mb-1"></small>
<small id="current-step-progress" class="text-muted mb-1"></small>
</div>
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for detailer in detailers %}
<div class="col" id="card-{{ detailer.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/detailer/{{ detailer.slug }}'">
<div class="card h-100 character-card {% if request.args.get('highlight') == detailer.slug %}border-success border-3 highlight-card{% endif %}" onclick="window.location.href='/detailer/{{ detailer.slug }}'">
<div class="img-container">
{% if detailer.image_path %}
<img id="img-{{ detailer.slug }}" src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}">
@@ -92,15 +65,29 @@
{% endblock %}
{% block scripts %}
<style>
.highlight-card {
animation: highlight-pulse 2s ease-in-out 3;
box-shadow: 0 0 20px rgba(25, 135, 84, 0.5) !important;
}
@keyframes highlight-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(25, 135, 84, 0.5); }
50% { box-shadow: 0 0 30px rgba(25, 135, 84, 0.8); }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const progressBar = document.getElementById('batch-progress-bar');
const taskProgressBar = document.getElementById('task-progress-bar');
const container = document.getElementById('batch-progress-container');
const statusText = document.getElementById('batch-status-text');
const nodeStatus = document.getElementById('batch-node-status');
const detailerNameText = document.getElementById('current-detailer-name');
const stepProgressText = document.getElementById('current-step-progress');
@@ -131,17 +118,12 @@
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
// Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const item of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/detailer/${item.slug}/generate`, {
method: 'POST',
@@ -156,12 +138,6 @@
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
@@ -177,24 +153,11 @@
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%';
progressBar.textContent = '100%';
statusText.textContent = 'Batch Detailer Generation Complete!';
detailerNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => container.classList.add('d-none'), 5000);
alert(`Batch generation complete! ${jobs.length} detailer images processed.`);
}
batchBtn.addEventListener('click', async () => {

View File

@@ -126,11 +126,12 @@
{% if wardrobe_data.default is defined and wardrobe_data.default is mapping %}
{# New nested format - show tabs for each outfit #}
<ul class="nav nav-tabs mb-3" id="wardrobeTabs" role="tablist">
{% for outfit_name in outfits %}
{% for outfit in outfits %}
{% set outfit_id = outfit.outfit_id %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if loop.first %}active{% endif %}" id="outfit-{{ outfit_name }}-tab" data-bs-toggle="tab" data-bs-target="#outfit-{{ outfit_name }}" type="button" role="tab">
{{ outfit_name }}
{% if outfit_name == character.active_outfit %}
<button class="nav-link {% if loop.first %}active{% endif %}" id="outfit-{{ outfit_id }}-tab" data-bs-toggle="tab" data-bs-target="#outfit-{{ outfit_id }}" type="button" role="tab">
{{ outfit.name }}
{% if outfit_id == character.active_outfit %}
<span class="badge bg-primary ms-1">Active</span>
{% endif %}
</button>
@@ -138,25 +139,32 @@
{% endfor %}
</ul>
<div class="tab-content" id="wardrobeTabContent">
{% for outfit_name in outfits %}
<div class="tab-pane fade {% if loop.first %}show active{% endif %}" id="outfit-{{ outfit_name }}" role="tabpanel">
{% for outfit in outfits %}
{% set outfit_id = outfit.outfit_id %}
<div class="tab-pane fade {% if loop.first %}show active{% endif %}" id="outfit-{{ outfit_id }}" role="tabpanel">
<div class="d-flex justify-content-end mb-2">
{% if outfit_name != 'default' %}
{% if outfit_id != 'default' %}
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#renameOutfitModal" data-outfit="{{ outfit_name }}">Rename</button>
<form action="{{ url_for('delete_outfit', slug=character.slug) }}" method="post" class="d-inline" onsubmit="return confirm('Delete outfit \'{{ outfit_name }}\'?');">
<input type="hidden" name="outfit" value="{{ outfit_name }}">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#renameOutfitModal" data-outfit="{{ outfit_id }}">Rename</button>
<form action="{{ url_for('delete_outfit', slug=character.slug) }}" method="post" class="d-inline" onsubmit="return confirm('Delete outfit \'{{ outfit_id }}\'?');">
<input type="hidden" name="outfit" value="{{ outfit_id }}">
<button type="submit" class="btn btn-outline-danger">Delete</button>
</form>
</div>
{% endif %}
</div>
{% for key, value in wardrobe_data[outfit_name].items() %}
<div class="mb-3">
<label for="wardrobe_{{ outfit_name }}_{{ key }}" class="form-label text-capitalize">{{ key.replace('_', ' ') }}</label>
<input type="text" class="form-control" id="wardrobe_{{ outfit_name }}_{{ key }}" name="wardrobe_{{ outfit_name }}_{{ key }}" value="{{ value }}">
</div>
{% endfor %}
{% if outfit.source == 'embedded' and outfit_id in wardrobe_data %}
{% for key, value in wardrobe_data[outfit_id].items() %}
<div class="mb-3">
<label for="wardrobe_{{ outfit_id }}_{{ key }}" class="form-label text-capitalize">{{ key.replace('_', ' ') }}</label>
<input type="text" class="form-control" id="wardrobe_{{ outfit_id }}_{{ key }}" name="wardrobe_{{ outfit_id }}_{{ key }}" value="{{ value }}">
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> This is an assigned external outfit. It cannot be edited directly from the character profile.
</div>
{% endif %}
</div>
{% endfor %}
</div>

View File

@@ -43,6 +43,7 @@
<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>
<option value="random" {% if sort == 'random' %}selected{% endif %}>🎲 Random</option>
</select>
</div>
@@ -76,6 +77,133 @@
</div>
</form>
<!-- Quick Resource Filters -->
<div class="mb-3">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="text-muted small me-2">Quick filters:</span>
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'all' %}bg-secondary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
All
</a>
<a href="{{ url_for('gallery', category='characters', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'characters' %}bg-primary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Characters
</a>
<a href="{{ url_for('gallery', category='looks', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'looks' %}bg-primary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Looks
</a>
<a href="{{ url_for('gallery', category='outfits', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'outfits' %}bg-success{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Outfits
</a>
<a href="{{ url_for('gallery', category='actions', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'actions' %}bg-danger{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Actions
</a>
<a href="{{ url_for('gallery', category='scenes', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'scenes' %}bg-info{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Scenes
</a>
<a href="{{ url_for('gallery', category='styles', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'styles' %}bg-warning{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Styles
</a>
<a href="{{ url_for('gallery', category='detailers', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'detailers' %}bg-secondary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Detailers
</a>
<a href="{{ url_for('gallery', category='checkpoints', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'checkpoints' %}bg-dark{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Checkpoints
</a>
</div>
</div>
<!-- Gallery View Mode Controls -->
<div class="gallery-controls" id="gallery-controls">
<div class="gallery-view-modes">
<button class="gallery-view-btn active" data-mode="grid" title="Info view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Info
</button>
<button class="gallery-view-btn" data-mode="mosaic" title="Gallery view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="6" height="6"></rect>
<rect x="11" y="3" width="6" height="6"></rect>
<rect x="3" y="11" width="6" height="6"></rect>
<rect x="11" y="11" width="6" height="6"></rect>
</svg>
Gallery
</button>
</div>
<div class="gallery-selection-controls">
<button class="gallery-view-btn" id="selection-mode-toggle" title="Multi-select mode">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<path d="M9 11l3 3 6-6"></path>
</svg>
Select
</button>
<button class="btn btn-sm btn-danger d-none" id="delete-selected-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="me-1">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete Selected (<span id="selected-count">0</span>)
</button>
</div>
<div class="gallery-slideshow-dropdown">
<button class="gallery-slideshow-btn" id="slideshow-menu-btn" aria-haspopup="true" aria-expanded="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
Slideshow ▾
</button>
<div class="gallery-slideshow-menu" id="slideshow-menu">
<div class="gallery-slideshow-menu-item" data-mode="cinema">
<span>🎬</span>
<div>
<div class="fw-medium">Cinema</div>
<small class="text-muted">Ambient glow & Ken Burns</small>
</div>
</div>
<div class="gallery-slideshow-menu-item" data-mode="classic">
<span></span>
<div>
<div class="fw-medium">Classic</div>
<small class="text-muted">Clean transitions</small>
</div>
</div>
<div class="gallery-slideshow-menu-item" data-mode="showcase">
<span>🖼</span>
<div>
<div class="fw-medium">Showcase</div>
<small class="text-muted">Digital frame style</small>
</div>
</div>
<div class="gallery-slideshow-menu-item" data-mode="ambient">
<span>🌌</span>
<div>
<div class="fw-medium">Ambient</div>
<small class="text-muted">Screensaver with particles</small>
</div>
</div>
</div>
</div>
<div class="ms-auto small text-muted d-none d-md-block">
<kbd>G</kbd> Info · <kbd>A</kbd> Gallery · <kbd>S</kbd> Slideshow
</div>
</div>
<!-- Showing XY of N -->
{% if total > 0 %}
<p class="text-muted small mb-2">
@@ -101,19 +229,47 @@
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)">
data-src="{{ url_for('static', filename='uploads/' + img.path) }}">
<input type="checkbox" class="gallery-card-checkbox d-none" data-path="{{ img.path }}" onclick="event.stopPropagation()">
<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>
<!-- Info View Additional Metadata -->
<div class="info-meta">
{% if img.meta %}
{% if img.meta.checkpoint %}
<span class="badge bg-dark mb-1 d-inline-block text-truncate w-100" style="font-size: 0.65rem;" title="{{ img.meta.checkpoint }}">{{ img.meta.checkpoint.split('/')[-1] }}</span>
{% endif %}
<div class="d-flex flex-wrap gap-1">
{% for lora in img.meta.loras %}
{% set lora_name = lora.name.split('/')[-1].replace('.safetensors', '') %}
{% set subfolder = lora.name.split('/')[1] if lora.name.startswith('Illustrious/') else '' %}
{% if subfolder == 'Characters' %}{% set color = 'primary' %}
{% elif subfolder == 'Looks' %}{% set color = 'primary' %}
{% elif subfolder == 'Clothing' %}{% set color = 'success' %}
{% elif subfolder == 'Actions' %}{% set color = 'danger' %}
{% elif subfolder == 'Scenes' %}{% set color = 'info' %}
{% elif subfolder == 'Styles' %}{% set color = 'warning' %}
{% elif subfolder == 'Detailers' %}{% set color = 'secondary' %}
{% else %}{% set color = 'light text-dark' %}
{% endif %}
<span class="badge bg-{{ color }}" style="font-size: 0.6rem;" title="{{ lora.name }}">{{ lora_name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<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 }})">
onclick='event.stopPropagation(); showPrompt({{ img.path | tojson }}, {{ img.item_name | tojson }}, {{ img.category | tojson }}, {{ img.slug | tojson }})'>
Prompt
</button>
{% if img.category == 'characters' %}
@@ -131,7 +287,7 @@
{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-2"
title="Delete"
onclick="event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})">
onclick='event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})'>
🗑
</button>
</div>
@@ -176,19 +332,6 @@
</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>
<div id="lightbox-actions" onclick="event.stopPropagation()">
<button class="btn btn-sm btn-light" onclick="lightboxShowPrompt()">View Prompt</button>
<button class="btn btn-sm btn-outline-danger" onclick="openDeleteModal(_lightboxPath, _lightboxName); closeLightbox()">Delete</button>
</div>
</div>
<!-- Prompt modal -->
<div class="modal fade" id="promptModal" tabindex="-1">
@@ -267,38 +410,6 @@
{% 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', () => {
@@ -428,5 +539,240 @@ async function confirmDelete() {
alert('Delete failed: ' + e);
}
}
// ============================================================
// GAZE Gallery Enhancement - View Mode Controls
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
// Initialize GalleryCore if available
if (window.GalleryCore) {
GalleryCore.init('.gallery-grid');
console.log('GalleryCore initialized');
}
// View mode buttons
const viewButtons = document.querySelectorAll('.gallery-view-btn');
viewButtons.forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
// Update active state
viewButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Apply layout
if (window.GalleryCore) {
GalleryCore.setLayout(mode);
}
// Save preference
localStorage.setItem('gaze-gallery-view', mode);
});
});
// Restore saved view mode
const savedMode = localStorage.getItem('gaze-gallery-view');
if (savedMode) {
const btn = document.querySelector(`.gallery-view-btn[data-mode="${savedMode}"]`);
if (btn) {
viewButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
}
// Slideshow dropdown
const slideshowBtn = document.getElementById('slideshow-menu-btn');
const slideshowMenu = document.getElementById('slideshow-menu');
if (slideshowBtn && slideshowMenu) {
slideshowBtn.addEventListener('click', (e) => {
e.stopPropagation();
slideshowMenu.classList.toggle('open');
slideshowBtn.setAttribute('aria-expanded', slideshowMenu.classList.contains('open'));
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!slideshowMenu.contains(e.target) && e.target !== slideshowBtn) {
slideshowMenu.classList.remove('open');
slideshowBtn.setAttribute('aria-expanded', 'false');
}
});
// Slideshow mode selection
slideshowMenu.querySelectorAll('.gallery-slideshow-menu-item').forEach(item => {
item.addEventListener('click', () => {
const mode = item.dataset.mode;
slideshowMenu.classList.remove('open');
slideshowBtn.setAttribute('aria-expanded', 'false');
// Start slideshow
if (window.GalleryCore) {
GalleryCore.startSlideshow(mode);
}
});
});
}
// ============================================================
// Multi-Selection Mode
// ============================================================
let selectionMode = false;
const selectedPaths = new Set();
const selectionToggleBtn = document.getElementById('selection-mode-toggle');
const deleteSelectedBtn = document.getElementById('delete-selected-btn');
const selectedCountEl = document.getElementById('selected-count');
const allCheckboxes = document.querySelectorAll('.gallery-card-checkbox');
const galleryGrid = document.querySelector('.gallery-grid');
// Toggle selection mode
selectionToggleBtn.addEventListener('click', () => {
selectionMode = !selectionMode;
if (selectionMode) {
// Enter selection mode
selectionToggleBtn.classList.add('active');
galleryGrid.classList.add('selection-mode');
allCheckboxes.forEach(cb => cb.classList.remove('d-none'));
// Modify card click behavior
document.querySelectorAll('.gallery-card').forEach(card => {
card.style.cursor = 'pointer';
card.onclick = function(e) {
e.stopPropagation();
e.preventDefault();
// In selection mode, clicking ANYWHERE on the card toggles checkbox
const checkbox = this.querySelector('.gallery-card-checkbox');
checkbox.checked = !checkbox.checked;
handleCheckboxChange(checkbox);
};
});
} else {
// Exit selection mode
selectionToggleBtn.classList.remove('active');
galleryGrid.classList.remove('selection-mode');
allCheckboxes.forEach(cb => {
cb.classList.add('d-none');
cb.checked = false;
});
deleteSelectedBtn.classList.add('d-none');
selectedPaths.clear();
// Restore original click behavior
document.querySelectorAll('.gallery-card').forEach(card => {
card.style.cursor = '';
card.onclick = null;
});
}
});
// Handle checkbox changes
function handleCheckboxChange(checkbox) {
const path = checkbox.dataset.path;
if (checkbox.checked) {
selectedPaths.add(path);
checkbox.closest('.gallery-card').classList.add('selected');
} else {
selectedPaths.delete(path);
checkbox.closest('.gallery-card').classList.remove('selected');
}
// Update UI
selectedCountEl.textContent = selectedPaths.size;
if (selectedPaths.size > 0) {
deleteSelectedBtn.classList.remove('d-none');
} else {
deleteSelectedBtn.classList.add('d-none');
}
}
// Attach checkbox listeners
allCheckboxes.forEach(cb => {
cb.addEventListener('change', () => handleCheckboxChange(cb));
});
// Delete selected images
deleteSelectedBtn.addEventListener('click', async () => {
if (selectedPaths.size === 0) return;
const count = selectedPaths.size;
if (!confirm(`Delete ${count} selected image${count !== 1 ? 's' : ''}? This cannot be undone.`)) {
return;
}
// Show loading state
deleteSelectedBtn.disabled = true;
deleteSelectedBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span> Deleting...`;
// Delete all selected images
const pathsToDelete = Array.from(selectedPaths);
let successCount = 0;
let failCount = 0;
for (const path of pathsToDelete) {
try {
const res = await fetch('/gallery/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path}),
});
const data = await res.json();
if (data.status === 'ok') {
// Remove card from DOM
const card = document.querySelector(`.gallery-card[data-path="${CSS.escape(path)}"]`);
if (card) card.remove();
selectedPaths.delete(path);
successCount++;
} else {
failCount++;
}
} catch (e) {
console.error(`Failed to delete ${path}:`, e);
failCount++;
}
}
// Update count in header
const countEl = document.querySelector('h4 .text-muted');
if (countEl) {
const m = countEl.textContent.match(/(\d+)/);
if (m) {
const n = parseInt(m[1]) - successCount;
countEl.textContent = ` ${n} image${n !== 1 ? 's' : ''}`;
}
}
// Reset button state
deleteSelectedBtn.disabled = false;
deleteSelectedBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="me-1">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete Selected (<span id="selected-count">0</span>)
`;
// Update selected count reference
selectedCountEl = document.getElementById('selected-count');
selectedCountEl.textContent = selectedPaths.size;
if (selectedPaths.size === 0) {
deleteSelectedBtn.classList.add('d-none');
}
// Show result message
if (successCount > 0) {
window.location.reload();
} else if (failCount > 0) {
alert(`Failed to delete ${failCount} image${failCount !== 1 ? 's' : ''}`);
}
});
});
</script>
<!-- Load Gallery Enhancement Script -->
<script src="{{ url_for('static', filename='js/gallery/gallery-core.js') }}"></script>
{% endblock %}

View File

@@ -18,9 +18,14 @@
<!-- 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>
<button type="submit" class="btn btn-primary" id="generate-btn" data-requires="comfyui">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">
<div class="input-group input-group-sm" style="width:180px">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<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">
@@ -364,6 +369,7 @@
if (placeholder) placeholder.classList.add('d-none');
resultFooter.classList.remove('d-none');
}
updateSeedFromResult(jobResult.result);
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}

View File

@@ -4,41 +4,14 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Character Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('rescan') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan character files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
<!-- Batch Progress Bar -->
<div id="batch-progress-container" class="card mb-4 d-none">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 id="batch-status-text" class="mb-0">Batch Generating...</h5>
<span id="batch-node-status" class="badge bg-info">Starting...</span>
</div>
<div class="mb-3">
<small class="text-muted">Overall Batch Progress</small>
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<small id="current-char-name" class="text-muted mb-1"></small>
<small id="current-step-progress" class="text-muted mb-1"></small>
</div>
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for char in characters %}
<div class="col" id="card-{{ char.slug }}">
@@ -95,13 +68,6 @@
document.addEventListener('DOMContentLoaded', () => {
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const progressBar = document.getElementById('batch-progress-bar');
const taskProgressBar = document.getElementById('task-progress-bar');
const container = document.getElementById('batch-progress-container');
const statusText = document.getElementById('batch-status-text');
const nodeStatus = document.getElementById('batch-node-status');
const charNameText = document.getElementById('current-char-name');
const stepProgressText = document.getElementById('current-step-progress');
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -130,17 +96,10 @@
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
// Phase 1: Queue all jobs upfront so the page can be navigated away from
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const char of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/character/${char.slug}/generate`, {
method: 'POST',
@@ -155,16 +114,7 @@
}
// Phase 2: Poll all jobs concurrently; update UI as each finishes
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
charNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
@@ -176,24 +126,11 @@
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%';
progressBar.textContent = '100%';
statusText.textContent = 'Batch Complete!';
charNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => container.classList.add('d-none'), 5000);
alert(`Batch generation complete! ${jobs.length} images queued.`);
}
batchBtn.addEventListener('click', async () => {

View File

@@ -41,7 +41,7 @@
<span class="status-dot status-checking"></span>
<span class="status-label d-none d-xl-inline">ComfyUI</span>
</span>
<span id="status-mcp" class="service-status" title="MCP" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="Danbooru MCP: checking…">
<span id="status-mcp" class="service-status" title="MCP" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="MCP: checking…">
<span class="status-dot status-checking"></span>
<span class="status-label d-none d-xl-inline">MCP</span>
</span>
@@ -111,12 +111,41 @@
</div>
<div class="modal-footer">
<small class="text-muted me-auto">Jobs are processed sequentially. Close this window to continue browsing.</small>
<button type="button" id="queue-clear-btn" class="btn btn-warning btn-sm" onclick="queueClearAll()">
🗑️ Clear Queue
</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Gallery Navigation Modal -->
<div class="modal fade" id="galleryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center position-relative">
<!-- Navigation Arrows -->
<button type="button" id="gallery-prev" aria-label="Previous image" class="btn btn-dark btn-lg position-absolute start-0 top-50 translate-middle-y ms-2 opacity-75 hover-opacity-100"
style="z-index: 1050; border-radius: 50%; width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
&#8249;
</button>
<button type="button" id="gallery-next" aria-label="Next image" class="btn btn-dark btn-lg position-absolute end-0 top-50 translate-middle-y me-2 opacity-75 hover-opacity-100"
style="z-index: 1050; border-radius: 50%; width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
&#8250;
</button>
<!-- Image Counter -->
<div id="gallery-counter" class="position-absolute bottom-0 start-50 translate-middle-x mb-3 px-3 py-1 bg-dark rounded text-white opacity-75"
style="z-index: 1050; font-size: 0.9rem;">
1 / 1
</div>
<!-- Main Image -->
<img id="galleryImage" src="" alt="Gallery Image" class="img-fluid" style="max-height: 90vh; cursor: default;">
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
@@ -345,11 +374,104 @@
// ---- Service status indicators ----
(function () {
const services = [
{ id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' },
{ id: 'status-mcp', url: '/api/status/mcp', label: 'Danbooru MCP' },
{ id: 'status-llm', url: '/api/status/llm', label: 'LLM' },
{ id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' },
{ id: 'status-llm', url: '/api/status/llm', label: 'LLM' },
];
// MCP services (checked separately for combined indicator)
const mcpServices = [
{ url: '/api/status/mcp', label: 'Danbooru MCP', key: 'danbooru' },
{ url: '/api/status/character-mcp', label: 'Character MCP', key: 'character' },
];
// Global service status tracker
window.serviceStatus = {
comfyui: false,
mcp: false,
characterMcp: false,
llm: false,
mcpStatuses: {
danbooru: false,
character: false
}
};
function updateButtons() {
// Image generation buttons require ComfyUI
const imageGenButtons = document.querySelectorAll('.btn-image-gen, [data-requires="comfyui"], #generate-btn, button[form="generate-form"], #batch-generate-btn, #regenerate-all-btn, #generate-all-btn');
imageGenButtons.forEach(btn => {
if (!window.serviceStatus.comfyui) {
btn.disabled = true;
btn.title = btn.title || 'ComfyUI is not available';
} else {
btn.disabled = false;
}
});
// JSON generation buttons require Danbooru MCP or LLM
const jsonGenButtons = document.querySelectorAll('.btn-json-gen, [data-requires="mcp-llm"], button[data-bs-target="#jsonEditorModal"], #json-save-btn');
jsonGenButtons.forEach(btn => {
if (!window.serviceStatus.mcp && !window.serviceStatus.llm) {
btn.disabled = true;
btn.title = btn.title || 'Danbooru MCP or LLM is required';
} else {
btn.disabled = false;
}
});
// Character generation buttons require Character MCP or LLM
const charGenButtons = document.querySelectorAll('.btn-char-gen, [data-requires="char-mcp-llm"], #submit-btn, button[form*="create"]');
charGenButtons.forEach(btn => {
if (!window.serviceStatus.characterMcp && !window.serviceStatus.llm) {
btn.disabled = true;
btn.title = btn.title || 'Character MCP or LLM is required';
} else {
btn.disabled = false;
}
});
}
function updateMcpStatus() {
const el = document.getElementById('status-mcp');
if (!el) return;
const statuses = window.serviceStatus.mcpStatuses;
const allOnline = statuses.danbooru && statuses.character;
const allOffline = !statuses.danbooru && !statuses.character;
const someOffline = !allOnline && !allOffline;
// Update global status flags
window.serviceStatus.mcp = statuses.danbooru;
window.serviceStatus.characterMcp = statuses.character;
const dot = el.querySelector('.status-dot');
// Set status class: red (all offline), yellow (some offline), green (all online)
if (allOffline) {
dot.className = 'status-dot status-error';
} else if (someOffline) {
dot.className = 'status-dot status-warning';
} else {
dot.className = 'status-dot status-ok';
}
// Build detailed tooltip
const tooltipLines = [
'MCP Services:',
`Danbooru MCP: ${statuses.danbooru ? 'online' : 'offline'}`,
`Character MCP: ${statuses.character ? 'online' : 'offline'}`
];
const tooltipText = tooltipLines.join('\n');
el.setAttribute('data-bs-title', tooltipText);
el.setAttribute('title', tooltipText);
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
// Update buttons based on new status
updateButtons();
}
function setStatus(id, label, ok) {
const el = document.getElementById(id);
if (!el) return;
@@ -357,13 +479,19 @@
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
if (id === 'status-comfyui' && window._updateComfyTooltip) {
window._updateComfyTooltip();
return;
}
const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
el.setAttribute('data-bs-title', tooltipText);
el.setAttribute('title', tooltipText);
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
// Update global status
if (id === 'status-comfyui') window.serviceStatus.comfyui = ok;
if (id === 'status-llm') window.serviceStatus.llm = ok;
// Update buttons based on new status
updateButtons();
}
async function pollService(svc) {
@@ -376,8 +504,25 @@
}
}
function pollAll() {
services.forEach(pollService);
async function pollMcpService(svc) {
try {
const r = await fetch(svc.url, { cache: 'no-store' });
const data = await r.json();
window.serviceStatus.mcpStatuses[svc.key] = data.status === 'ok';
} catch {
window.serviceStatus.mcpStatuses[svc.key] = false;
}
}
async function pollAll() {
// Poll regular services
await Promise.all(services.map(pollService));
// Poll MCP services
await Promise.all(mcpServices.map(pollMcpService));
// Update combined MCP status
updateMcpStatus();
}
document.addEventListener('DOMContentLoaded', () => {
@@ -405,21 +550,48 @@
function renderQueue(jobs) {
const activeJobs = jobs.filter(j => !['done', 'failed', 'removed'].includes(j.status));
const pendingJobs = jobs.filter(j => j.status === 'pending');
const processingJob = jobs.find(j => j.status === 'processing');
const count = activeJobs.length;
const queueBtn = document.getElementById('queue-btn');
// Update badge
if (count > 0) {
badge.textContent = count;
badge.classList.remove('d-none');
document.getElementById('queue-btn').classList.add('queue-btn-active');
queueBtn.classList.add('queue-btn-active');
} else {
badge.classList.add('d-none');
document.getElementById('queue-btn').classList.remove('queue-btn-active');
queueBtn.classList.remove('queue-btn-active');
}
// Update generating animation and tooltip
if (processingJob) {
queueBtn.classList.add('queue-btn-generating');
queueBtn.title = `Generating: ${processingJob.label}`;
} else if (pendingJobs.length > 0) {
queueBtn.classList.remove('queue-btn-generating');
queueBtn.title = `${pendingJobs.length} job(s) queued`;
} else {
queueBtn.classList.remove('queue-btn-generating');
queueBtn.title = 'Generation Queue';
}
// Update modal count
if (modalCount) modalCount.textContent = jobs.length;
// Update Clear Queue button state
const clearBtn = document.getElementById('queue-clear-btn');
if (clearBtn) {
if (pendingJobs.length > 0) {
clearBtn.disabled = false;
clearBtn.title = `Clear ${pendingJobs.length} pending job(s)`;
} else {
clearBtn.disabled = true;
clearBtn.title = 'No pending jobs to clear';
}
}
// Render job list
if (!jobList) return;
if (jobs.length === 0) {
@@ -515,6 +687,27 @@
} catch (e) {}
}
async function queueClearAll() {
if (!confirm('Clear all pending jobs from the queue? The current generation will continue.')) {
return;
}
try {
const resp = await fetch('/api/queue/clear', { method: 'POST' });
const data = await resp.json();
if (data.removed_count > 0) {
alert(`Cleared ${data.removed_count} pending job(s) from the queue.`);
} else {
alert('No pending jobs to clear.');
}
fetchQueue();
} catch (e) {
alert('Error clearing queue: ' + e.message);
}
}
// Make queueClearAll globally accessible
window.queueClearAll = queueClearAll;
// Poll queue every 2 seconds
document.addEventListener('DOMContentLoaded', () => {
fetchQueue();
@@ -528,6 +721,240 @@
});
})();
</script>
<script>
// ---- Gallery Navigation System ----
(function() {
let galleryImages = [];
let currentIndex = 0;
let galleryModal = null;
document.addEventListener('DOMContentLoaded', () => {
const modalEl = document.getElementById('galleryModal');
if (modalEl) {
galleryModal = new bootstrap.Modal(modalEl);
// Previous button
document.getElementById('gallery-prev').addEventListener('click', (e) => {
e.stopPropagation();
navigateGallery(-1);
});
// Next button
document.getElementById('gallery-next').addEventListener('click', (e) => {
e.stopPropagation();
navigateGallery(1);
});
// Keyboard navigation
modalEl.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
navigateGallery(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
navigateGallery(1);
} else if (e.key === 'Escape') {
galleryModal.hide();
}
});
// Click on image to go next (optional convenience)
document.getElementById('galleryImage').addEventListener('click', () => {
navigateGallery(1);
});
}
});
function navigateGallery(direction) {
if (galleryImages.length === 0) return;
currentIndex += direction;
// Wrap around
if (currentIndex < 0) {
currentIndex = galleryImages.length - 1;
} else if (currentIndex >= galleryImages.length) {
currentIndex = 0;
}
updateGalleryImage();
}
function updateGalleryImage() {
const img = document.getElementById('galleryImage');
const counter = document.getElementById('gallery-counter');
if (galleryImages[currentIndex]) {
img.src = galleryImages[currentIndex];
counter.textContent = `${currentIndex + 1} / ${galleryImages.length}`;
}
}
// Global function to open gallery
window.openGallery = function(images, startIndex = 0) {
galleryImages = images || [];
currentIndex = startIndex;
if (galleryImages.length === 0) return;
updateGalleryImage();
galleryModal.show();
};
// Global function to register gallery images from a container
window.registerGallery = function(containerSelector, imageSelector) {
const container = document.querySelector(containerSelector);
if (!container) return [];
const images = [];
container.querySelectorAll(imageSelector).forEach((img, index) => {
const src = img.src || img.dataset.src;
if (src) {
images.push(src);
img.style.cursor = 'pointer';
img.addEventListener('click', () => {
openGallery(images, index);
});
}
});
return images;
};
})();
</script>
<script>
// Seed input: clear button and auto-populate from generation result
document.addEventListener('DOMContentLoaded', () => {
const seedInput = document.getElementById('seed-input');
const seedClearBtn = document.getElementById('seed-clear-btn');
if (seedClearBtn && seedInput) {
seedClearBtn.addEventListener('click', () => { seedInput.value = ''; });
}
});
// Global helper: update seed input after a generation job completes
function updateSeedFromResult(jobResult) {
const seedInput = document.getElementById('seed-input');
if (seedInput && jobResult?.seed != null) {
seedInput.value = jobResult.seed;
}
}
</script>
<script>
// Endless generation mode
(function() {
let _endless = false;
let _endlessCount = 0;
let _endlessAbort = null;
window._endlessActive = () => _endless;
window._endlessStart = function() {
const form = document.getElementById('generate-form');
const btn = document.getElementById('endless-btn');
const stopBtn = document.getElementById('endless-stop-btn');
const counter = document.getElementById('endless-counter');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const progressLabel = document.getElementById('progress-label');
if (!form || !btn) return;
_endless = true;
_endlessCount = 0;
_endlessAbort = new AbortController();
btn.classList.add('d-none');
stopBtn.classList.remove('d-none');
counter.classList.remove('d-none');
counter.textContent = '0 generated';
// Disable single generate button during endless
const genBtn = form.querySelector('button[value="preview"]');
if (genBtn) genBtn.disabled = true;
(async function loop() {
while (_endless) {
// Clear seed for random each time
const seedInput = document.getElementById('seed-input');
if (seedInput) seedInput.value = '';
const formData = new FormData(form);
formData.set('action', 'preview');
// Show progress
if (progressContainer) {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `Endless #${_endlessCount + 1} — Queuing…`;
}
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' },
signal: _endlessAbort.signal
});
const data = await response.json();
if (data.error) { console.error('Endless error:', data.error); break; }
if (progressLabel) progressLabel.textContent = `Endless #${_endlessCount + 1} — Generating…`;
// Poll for completion
const jobResult = await new Promise((resolve, reject) => {
const poll = setInterval(async () => {
if (!_endless) { clearInterval(poll); reject(new Error('stopped')); return; }
try {
const resp = await fetch(`/api/queue/${data.job_id}/status`);
const status = await resp.json();
if (status.status === 'done') { clearInterval(poll); resolve(status); }
else if (status.status === 'failed' || status.status === 'removed') {
clearInterval(poll); reject(new Error(status.error || 'Job failed'));
} else if (status.status === 'processing' && progressLabel) {
progressLabel.textContent = `Endless #${_endlessCount + 1} — Generating…`;
}
} catch (err) { /* keep polling */ }
}, 1500);
});
_endlessCount++;
counter.textContent = `${_endlessCount} generated`;
// Update preview via page-specific handler
if (jobResult.result?.image_url && window._onEndlessResult) {
window._onEndlessResult(jobResult);
}
updateSeedFromResult(jobResult.result);
} catch (err) {
if (err.name === 'AbortError' || err.message === 'stopped') break;
console.error('Endless generation error:', err);
break;
}
}
// Cleanup
window._endlessStop();
})();
};
window._endlessStop = function() {
_endless = false;
if (_endlessAbort) { _endlessAbort.abort(); _endlessAbort = null; }
const btn = document.getElementById('endless-btn');
const stopBtn = document.getElementById('endless-stop-btn');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const form = document.getElementById('generate-form');
if (btn) btn.classList.remove('d-none');
if (stopBtn) stopBtn.classList.add('d-none');
if (progressContainer) progressContainer.classList.add('d-none');
if (progressBar) progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
const genBtn = form?.querySelector('button[value="preview"]');
if (genBtn) genBtn.disabled = false;
};
// Stop on page leave
window.addEventListener('beforeunload', () => { if (_endless) window._endlessStop(); });
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,6 +1,40 @@
{% extends "layout.html" %}
{% block content %}
<!-- Generate Character Modal -->
<div class="modal fade" id="generateCharModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Generate Character from Look</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('generate_character_from_look', slug=look.slug) }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Character Name</label>
<input type="text" class="form-control" name="character_name"
value="{{ look.name }}" required>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="use_llm" id="use_llm" checked>
<label class="form-check-label" for="use_llm">
Use LLM to generate detailed character
</label>
</div>
<div class="form-text text-muted">
The look's LoRA will be automatically assigned to the new character.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Generate Character</button>
</div>
</form>
</div>
</div>
</div>
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
@@ -29,21 +63,10 @@
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if look.image_path %}
<img src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}" class="img-fluid"
data-preview-path="{{ look.image_path }}">
@@ -52,20 +75,12 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_look_image', slug=look.slug) }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="image" class="form-label">Update Image</label>
<input class="form-control" type="file" id="image" name="image" required>
</div>
<button type="submit" class="btn btn-primary w-100 mb-2">Upload</button>
</form>
{# Character Selector #}
<div class="mb-3">
<label for="character_select" class="form-label">Preview with Character</label>
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
<option value="">-- No Character --</option>
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
<option value="__random__" {% if selected_character == '__random__' or not selected_character %}selected{% endif %}>🎲 Random Character</option>
{% for char in characters %}
<option value="{{ char.slug }}"
{% if selected_character == char.character_id or selected_character == char.slug %}selected
@@ -73,11 +88,29 @@
{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
<div class="form-text">Defaults to the linked character.</div>
<div class="form-text">Defaults to the first linked character.</div>
</div>
{# Additional Prompts #}
<div class="mb-2">
<label for="extra_positive" class="form-label">Additional Positive</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality" form="generate-form">{{ extra_positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality" form="generate-form">{{ extra_negative or '' }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" form="generate-form" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form" data-requires="comfyui">Generate Preview</button>
<button type="button" class="btn btn-outline-info" id="endless-btn" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" class="btn btn-danger d-none" id="endless-stop-btn" onclick="window._endlessStop()">Stop Endless</button>
<small class="text-muted d-none" id="endless-counter"></small>
<button type="submit" form="generate-form" formaction="{{ url_for('save_look_defaults', slug=look.slug) }}" class="btn btn-sm btn-outline-secondary mt-2">Save as Default Selection</button>
</div>
</div>
@@ -99,7 +132,7 @@
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
@@ -114,10 +147,8 @@
{% if 'special::tags' in preferences %}checked{% endif %}
{% elif look.default_fields is not none %}
{% if 'special::tags' in look.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label text-white small" for="includeTags">Include</label>
<label class="form-check-label text-white small {% if look.default_fields is not none and 'special::tags' in look.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
</div>
</div>
<div class="card-body">
@@ -134,18 +165,41 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">{{ look.name }}</h1>
{% if look.character_id %}
<small class="text-muted">Linked to: <strong>{{ look.character_id.replace('_', ' ').title() }}</strong></small>
{% if linked_character_ids %}
<small class="text-muted">
Linked to:
{% for char_id in linked_character_ids %}
<a href="{{ url_for('detail', slug=char_id) }}" class="badge bg-primary text-decoration-none">{{ char_id.replace('_', ' ').title() }}</a>{% if not loop.last %} {% endif %}
{% endfor %}
</small>
{% endif %}
<br>
<a href="{{ url_for('edit_look', slug=look.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<button type="button" class="btn btn-accent" data-bs-toggle="modal" data-bs-target="#generateCharModal">
<i class="bi bi-person-plus"></i> Generate Character
</button>
<a href="{{ url_for('transfer_resource', category='looks', slug=look.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
<ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings-pane" type="button" role="tab">Settings</button>
</li>
{% if look.data.get('lora', {}).get('lora_name', '') != '' %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="strengths-tab" data-bs-toggle="tab" data-bs-target="#strengths-pane" type="button" role="tab">Strengths</button>
</li>
{% endif %}
</ul>
<div class="tab-content" id="detailTabContent">
<div class="tab-pane fade show active" id="settings-pane" role="tabpanel">
<form id="generate-form" action="{{ url_for('generate_look_image', slug=look.slug) }}" method="post">
{# Positive prompt #}
{% if look.data.positive %}
@@ -188,31 +242,40 @@
<div class="card-body">
<dl class="row mb-0">
{% for key, value in lora.items() %}
<dt class="col-sm-4 text-capitalize">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="lora::{{ key }}"
{% if preferences is not none %}
{% set is_default = look.default_fields is not none and ('lora::' ~ key) in look.default_fields %}
<dt class="col-sm-4 text-capitalize {% if is_default %}text-accent{% endif %}">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="lora::{{ key }}"
{% if preferences is not none %}
{% if 'lora::' + key in preferences %}checked{% endif %}
{% elif look.default_fields is not none %}
{% if 'lora::' + key in look.default_fields %}checked{% endif %}
{% if is_default %}checked{% endif %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{{ key.replace('_', ' ') }}
{% if is_default %}<span class="badge bg-primary ms-1" style="font-size: 0.55rem; vertical-align: middle;">DEF</span>{% endif %}
</dt>
<dd class="col-sm-8 {% if is_default %}text-accent{% endif %}">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
</form>
</div>{# /settings-pane #}
{% set sg_has_lora = look.data.get('lora', {}).get('lora_name', '') != '' %}
{% if sg_has_lora %}
<div class="tab-pane fade" id="strengths-pane" role="tabpanel">
{% set sg_entity = look %}
{% set sg_category = 'looks' %}
{% include 'partials/strengths_gallery.html' %}
</div>
{% endif %}
</div>{# /tab-content #}
</div>
</div>
{% set sg_entity = look %}
{% set sg_category = 'looks' %}
{% set sg_has_lora = look.data.get('lora', {}).get('lora_name', '') != '' %}
{% include 'partials/strengths_gallery.html' %}
{% endblock %}
{% block scripts %}
@@ -276,9 +339,14 @@
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
};
});
function showImage(src) {

View File

@@ -17,15 +17,27 @@
<label for="look_name" class="form-label">Display Name</label>
<input type="text" class="form-control" id="look_name" name="look_name" value="{{ look.name }}" required>
</div>
<!-- Multi-Character Selector -->
<div class="mb-3">
<label for="character_id" class="form-label">Linked Character</label>
<select class="form-select" id="character_id" name="character_id">
<option value="">— None —</option>
{% for char in characters %}
<option value="{{ char.character_id }}" {% if look.character_id == char.character_id %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
<div class="form-text">Associates this look with a character for generation and LoRA suggestions.</div>
<label class="form-label">Linked Characters</label>
<div class="card">
<div class="card-header bg-light">
<small class="text-muted">Check to link this look to characters</small>
</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
{% for char in characters %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="character_ids"
value="{{ char.character_id }}" id="char_{{ char.character_id }}"
{% if char.character_id in (look.character_ids or []) %}checked{% endif %}>
<label class="form-check-label" for="char_{{ char.character_id }}">
{{ char.name }}
</label>
</div>
{% endfor %}
</div>
</div>
<div class="form-text">Associates this look with multiple characters for generation and LoRA suggestions.</div>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (comma separated)</label>

View File

@@ -4,8 +4,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Looks Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new look entries from all LoRA files in the Looks folder"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
@@ -20,37 +20,10 @@
</div>
</div>
<!-- Batch Progress Bar -->
<div id="batch-progress-container" class="card mb-4 d-none">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 id="batch-status-text" class="mb-0">Batch Generating Looks...</h5>
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
</div>
<div class="mb-3">
<small class="text-muted">Overall Batch Progress</small>
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<small id="current-item-name" class="text-muted mb-1"></small>
<small id="current-step-progress" class="text-muted mb-1"></small>
</div>
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for look in looks %}
<div class="col" id="card-{{ look.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='{{ url_for('look_detail', slug=look.slug) }}'">
<div class="card h-100 character-card {% if request.args.get('highlight') == look.slug %}border-success border-3 highlight-card{% endif %}" onclick="window.location.href='{{ url_for('look_detail', slug=look.slug) }}'">
<div class="img-container">
{% if look.image_path %}
<img id="img-{{ look.slug }}" src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}">
@@ -96,15 +69,29 @@
{% endblock %}
{% block scripts %}
<style>
.highlight-card {
animation: highlight-pulse 2s ease-in-out 3;
box-shadow: 0 0 20px rgba(25, 135, 84, 0.5) !important;
}
@keyframes highlight-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(25, 135, 84, 0.5); }
50% { box-shadow: 0 0 30px rgba(25, 135, 84, 0.8); }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const progressBar = document.getElementById('batch-progress-bar');
const taskProgressBar = document.getElementById('task-progress-bar');
const container = document.getElementById('batch-progress-container');
const statusText = document.getElementById('batch-status-text');
const nodeStatus = document.getElementById('batch-node-status');
const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress');
@@ -153,17 +140,12 @@
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
// Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const item of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/look/${item.slug}/generate`, {
method: 'POST',
@@ -178,11 +160,6 @@
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
// Start polling queue for current job label
queuePollInterval = setInterval(updateCurrentJobLabel, 1000);
updateCurrentJobLabel(); // Initial update
@@ -200,11 +177,6 @@
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
// Stop polling queue
@@ -213,17 +185,9 @@
queuePollInterval = null;
}
progressBar.style.width = '100%';
progressBar.textContent = '100%';
statusText.textContent = 'Batch Look Generation Complete!';
itemNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => container.classList.add('d-none'), 5000);
alert(`Batch generation complete! ${jobs.length} look images processed.`);
}
batchBtn.addEventListener('click', async () => {

View File

@@ -29,21 +29,10 @@
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if outfit.image_path %}
<img src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}" class="img-fluid" data-preview-path="{{ outfit.image_path }}">
{% else %}
@@ -51,20 +40,12 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_outfit_image', slug=outfit.slug) }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="image" class="form-label">Update Image</label>
<input class="form-control" type="file" id="image" name="image" required>
</div>
<button type="submit" class="btn btn-primary w-100 mb-2">Upload</button>
</form>
{# Character Selector #}
<div class="mb-3">
<label for="character_select" class="form-label">Preview with Character</label>
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
<option value="">-- No Character (Outfit Only) --</option>
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
<option value="__random__" {% if selected_character == '__random__' or not selected_character %}selected{% endif %}>🎲 Random Character</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_character == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
@@ -72,8 +53,26 @@
<div class="form-text">Select a character to preview this outfit on their model.</div>
</div>
{# Additional Prompts #}
<div class="mb-2">
<label for="extra_positive" class="form-label">Additional Positive</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality" form="generate-form">{{ extra_positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality" form="generate-form">{{ extra_negative or '' }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" form="generate-form" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form" data-requires="comfyui">Generate Preview</button>
<button type="button" class="btn btn-outline-info" id="endless-btn" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" class="btn btn-danger d-none" id="endless-stop-btn" onclick="window._endlessStop()">Stop Endless</button>
<small class="text-muted d-none" id="endless-counter"></small>
<button type="submit" form="generate-form" formaction="{{ url_for('save_outfit_defaults', slug=outfit.slug) }}" class="btn btn-sm btn-outline-secondary mt-2">Save as Default Selection</button>
</div>
</div>
@@ -95,7 +94,7 @@
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
@@ -110,10 +109,8 @@
{% if 'special::tags' in preferences %}checked{% endif %}
{% elif outfit.default_fields is not none %}
{% if 'special::tags' in outfit.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label text-white small" for="includeTags">Include</label>
<label class="form-check-label text-white small {% if outfit.default_fields is not none and 'special::tags' in outfit.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
</div>
</div>
<div class="card-body">
@@ -137,10 +134,29 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('transfer_resource', category='outfits', slug=outfit.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
<!-- Linked Characters Section -->
{% if linked_characters %}
<div class="card mb-4 border-info">
<div class="card-header bg-info text-white">
<span><i class="bi bi-people"></i> Assigned to Characters</span>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2">
{% for char in linked_characters %}
<a href="{{ url_for('detail', slug=char.slug) }}" class="badge bg-secondary text-decoration-none">
{{ char.name }}
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings-pane" type="button" role="tab">Settings</button>
@@ -150,6 +166,11 @@
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
{% if outfit.data.get('lora', {}).get('lora_name', '') != '' %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="strengths-tab" data-bs-toggle="tab" data-bs-target="#strengths-pane" type="button" role="tab">Strengths</button>
</li>
{% endif %}
</ul>
<div class="tab-content" id="detailTabContent">
@@ -164,18 +185,20 @@
<div class="card-body">
<dl class="row mb-0">
{% for key, value in wardrobe.items() %}
<dt class="col-sm-4 text-capitalize">
{% set is_default = outfit.default_fields is not none and ('wardrobe::' ~ key) in outfit.default_fields %}
<dt class="col-sm-4 text-capitalize {% if is_default %}text-accent{% endif %}">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="wardrobe::{{ key }}"
{% if preferences is not none %}
{% if 'wardrobe::' + key in preferences %}checked{% endif %}
{% elif outfit.default_fields is not none %}
{% if 'wardrobe::' + key in outfit.default_fields %}checked{% endif %}
{% if is_default %}checked{% endif %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
{% if is_default %}<span class="badge bg-primary ms-1" style="font-size: 0.55rem; vertical-align: middle;">DEF</span>{% endif %}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
<dd class="col-sm-8 {% if is_default %}text-accent{% endif %}">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
@@ -189,18 +212,20 @@
<div class="card-body">
<dl class="row mb-0">
{% for key, value in lora.items() %}
<dt class="col-sm-4 text-capitalize">
{% set is_default = outfit.default_fields is not none and ('lora::' ~ key) in outfit.default_fields %}
<dt class="col-sm-4 text-capitalize {% if is_default %}text-accent{% endif %}">
<input class="form-check-input me-1" type="checkbox" name="include_field" value="lora::{{ key }}"
{% if preferences is not none %}
{% if 'lora::' + key in preferences %}checked{% endif %}
{% elif outfit.default_fields is not none %}
{% if 'lora::' + key in outfit.default_fields %}checked{% endif %}
{% if is_default %}checked{% endif %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
{% if is_default %}<span class="badge bg-primary ms-1" style="font-size: 0.55rem; vertical-align: middle;">DEF</span>{% endif %}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
<dd class="col-sm-8 {% if is_default %}text-accent{% endif %}">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
@@ -213,7 +238,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">{{ existing_previews|length }} preview(s)</span>
<div class="d-flex gap-2">
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm">Generate All Characters</button>
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm" data-requires="comfyui">Generate All Characters</button>
<button type="button" id="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
@@ -229,8 +254,8 @@
<img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
data-preview-path="{{ img }}">
</div>
{% else %}
@@ -238,14 +263,18 @@
{% endfor %}
</div>
</div>
{% set sg_has_lora = outfit.data.get('lora', {}).get('lora_name', '') != '' %}
{% if sg_has_lora %}
<div class="tab-pane fade" id="strengths-pane" role="tabpanel">
{% set sg_entity = outfit %}
{% set sg_category = 'outfits' %}
{% include 'partials/strengths_gallery.html' %}
</div>
{% endif %}
</div>
</div>
</div>
{% set sg_entity = outfit %}
{% set sg_category = 'outfits' %}
{% set sg_has_lora = outfit.data.get('lora', {}).get('lora_name', '') != '' %}
{% include 'partials/strengths_gallery.html' %}
{% endblock %}
{% block scripts %}
@@ -328,6 +357,7 @@
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
@@ -337,6 +367,14 @@
}
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
@@ -358,8 +396,8 @@
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
@@ -433,8 +471,6 @@
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}
</script>
{% endblock %}

View File

@@ -4,8 +4,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Outfit Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new outfit entries from all LoRA files in the Clothing folder"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
@@ -20,37 +20,10 @@
</div>
</div>
<!-- Batch Progress Bar -->
<div id="batch-progress-container" class="card mb-4 d-none">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 id="batch-status-text" class="mb-0">Batch Generating Outfits...</h5>
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
</div>
<div class="mb-3">
<small class="text-muted">Overall Batch Progress</small>
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<small id="current-item-name" class="text-muted mb-1"></small>
<small id="current-step-progress" class="text-muted mb-1"></small>
</div>
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for outfit in outfits %}
<div class="col" id="card-{{ outfit.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/outfit/{{ outfit.slug }}'">
<div class="card h-100 character-card {% if request.args.get('highlight') == outfit.slug %}border-success border-3 highlight-card{% endif %}" onclick="window.location.href='/outfit/{{ outfit.slug }}'">
<div class="img-container">
{% if outfit.image_path %}
<img id="img-{{ outfit.slug }}" src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}">
@@ -93,15 +66,29 @@
{% endblock %}
{% block scripts %}
<style>
.highlight-card {
animation: highlight-pulse 2s ease-in-out 3;
box-shadow: 0 0 20px rgba(25, 135, 84, 0.5) !important;
}
@keyframes highlight-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(25, 135, 84, 0.5); }
50% { box-shadow: 0 0 30px rgba(25, 135, 84, 0.8); }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const progressBar = document.getElementById('batch-progress-bar');
const taskProgressBar = document.getElementById('task-progress-bar');
const container = document.getElementById('batch-progress-container');
const statusText = document.getElementById('batch-status-text');
const nodeStatus = document.getElementById('batch-node-status');
const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress');
@@ -132,17 +119,12 @@
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
// Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const item of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/outfit/${item.slug}/generate`, {
method: 'POST',
@@ -157,12 +139,6 @@
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
@@ -178,24 +154,11 @@
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%';
progressBar.textContent = '100%';
statusText.textContent = 'Batch Outfit Generation Complete!';
itemNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => container.classList.add('d-none'), 5000);
alert(`Batch generation complete! ${jobs.length} outfit images processed.`);
}
batchBtn.addEventListener('click', async () => {

View File

@@ -223,8 +223,8 @@
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')">
<img src="${imageUrl}" class="card-img-top sg-gallery-img" style="object-fit:cover;height:160px;cursor:zoom-in;"
loading="lazy" data-src="${imageUrl}">
<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">
@@ -236,6 +236,15 @@
</div>
</div>`;
grid.appendChild(col);
// Add click handler for gallery navigation
const img = col.querySelector('.sg-gallery-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#sg-grid .sg-gallery-img')).map(i => i.dataset.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
sgUpdateBadge();
sgHighlightBounds();
}

View File

@@ -29,17 +29,6 @@
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ url_for('presets_index') }}" class="btn btn-sm btn-outline-secondary me-2">Back to Library</a>
@@ -59,8 +48,7 @@
<div class="col-md-4">
<div class="card mb-3">
<div class="img-container" style="height:auto;min-height:400px;cursor:pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if preset.image_path %}
<img src="{{ url_for('static', filename='uploads/' + preset.image_path) }}"
alt="{{ preset.name }}" class="img-fluid"
@@ -72,18 +60,28 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_preset_image', slug=preset.slug) }}" method="post" enctype="multipart/form-data" class="mb-3">
<label class="form-label text-muted small">Update Cover Image</label>
<div class="input-group input-group-sm">
<input class="form-control" type="file" name="image" required>
<button type="submit" class="btn btn-outline-primary">Upload</button>
</div>
</form>
<form id="generate-form" action="{{ url_for('generate_preset_image', slug=preset.slug) }}" method="post">
{# Additional Prompts #}
<div class="mb-2">
<label for="extra_positive" class="form-label">Additional Positive</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality">{{ extra_positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality">{{ extra_negative or '' }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success">Generate Preview</button>
<button type="submit" name="action" value="replace" class="btn btn-outline-warning btn-sm">Generate &amp; Set Cover</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" data-requires="comfyui">Generate Preview</button>
<button type="button" id="endless-btn" class="btn btn-outline-success" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" id="endless-stop-btn" class="btn btn-danger d-none" onclick="window._endlessStop()">Stop</button>
<small id="endless-counter" class="text-muted d-none"></small>
<button type="submit" name="action" value="replace" class="btn btn-outline-warning btn-sm" data-requires="comfyui">Generate &amp; Set Cover</button>
</div>
</form>
</div>
@@ -280,7 +278,7 @@ document.getElementById('generate-form').addEventListener('submit', function(e)
btn.disabled = true;
btn.textContent = 'Generating...';
fetch(this.action, {
fetch(this.getAttribute('action'), {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData
@@ -319,6 +317,7 @@ function pollJob(jobId, btn, actionVal) {
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
selectPreview(data.result.relative_path, data.result.image_url);
updateSeedFromResult(data.result);
} else if (data.status === 'failed') {
btn.disabled = false;
btn.textContent = 'Generate Preview';
@@ -346,6 +345,23 @@ document.addEventListener('click', function(e) {
if (img) selectPreview(img.dataset.previewPath, img.src);
});
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
const img = document.createElement('img');
img.src = jobResult.result.image_url;
img.className = 'img-fluid rounded';
img.style.cursor = 'pointer';
img.dataset.previewPath = jobResult.result.relative_path;
img.addEventListener('click', () => selectPreview(jobResult.result.relative_path, img.src));
const col = document.createElement('div');
col.className = 'col-4 col-md-3';
col.appendChild(img);
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
}
};
// JSON editor
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
</script>

View File

@@ -29,17 +29,6 @@
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
{% macro selection_checkbox(section, key, label, value) %}
<input class="form-check-input me-1" type="checkbox" name="include_field" value="{{ section }}::{{ key }}"
{% if preferences is not none %}
@@ -54,7 +43,7 @@
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if scene.image_path %}
<img src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}" class="img-fluid" data-preview-path="{{ scene.image_path }}">
{% else %}
@@ -64,30 +53,38 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_scene_image', slug=scene.slug) }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="image" class="form-label text-muted small">Update Cover Image</label>
<input class="form-control form-control-sm" type="file" id="image" name="image" required>
</div>
<button type="submit" class="btn btn-sm btn-outline-primary w-100 mb-2">Upload</button>
</form>
<hr>
{# Character Selector #}
<div class="mb-3">
<label for="character_select" class="form-label">Preview with Character</label>
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
<option value="">-- No Character (Scene Only) --</option>
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
<option value="__random__" {% if selected_character == '__random__' or not selected_character %}selected{% endif %}>🎲 Random Character</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_character == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
</div>
{# Additional Prompts #}
<div class="mb-2">
<label for="extra_positive" class="form-label">Additional Positive</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality" form="generate-form">{{ extra_positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality" form="generate-form">{{ extra_negative or '' }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" form="generate-form" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form" data-requires="comfyui">Generate Preview</button>
<button type="button" class="btn btn-outline-info" id="endless-btn" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" class="btn btn-danger d-none" id="endless-stop-btn" onclick="window._endlessStop()">Stop Endless</button>
<small class="text-muted d-none" id="endless-counter"></small>
<button type="submit" form="generate-form" formaction="{{ url_for('save_scene_defaults', slug=scene.slug) }}" class="btn btn-sm btn-outline-secondary mt-2">Save Selection as Default</button>
</div>
</div>
@@ -109,7 +106,7 @@
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
@@ -130,6 +127,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('transfer_resource', category='scenes', slug=scene.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -143,6 +141,11 @@
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
{% if scene.data.get('lora', {}).get('lora_name', '') != '' %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="strengths-tab" data-bs-toggle="tab" data-bs-target="#strengths-pane" type="button" role="tab">Strengths</button>
</li>
{% endif %}
</ul>
<div class="tab-content" id="detailTabContent">
@@ -223,7 +226,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">{{ existing_previews|length }} preview(s)</span>
<div class="d-flex gap-2">
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm">Generate All Characters</button>
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm" data-requires="comfyui">Generate All Characters</button>
<button type="button" id="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
@@ -237,10 +240,8 @@
{% for img in existing_previews %}
<div class="col">
<img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded"
class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
@@ -248,14 +249,18 @@
{% endfor %}
</div>
</div>
{% set sg_has_lora = scene.data.get('lora', {}).get('lora_name', '') != '' %}
{% if sg_has_lora %}
<div class="tab-pane fade" id="strengths-pane" role="tabpanel">
{% set sg_entity = scene %}
{% set sg_category = 'scenes' %}
{% include 'partials/strengths_gallery.html' %}
</div>
{% endif %}
</div>
</div>
</div>
{% set sg_entity = scene %}
{% set sg_category = 'scenes' %}
{% set sg_has_lora = scene.data.get('lora', {}).get('lora_name', '') != '' %}
{% include 'partials/strengths_gallery.html' %}
{% endblock %}
{% block scripts %}
@@ -328,10 +333,19 @@
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
@@ -349,16 +363,23 @@
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
col.innerHTML = `<div class="position-relative preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
@@ -416,10 +437,9 @@
});
initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
});
</script>
{% endblock %}

View File

@@ -4,8 +4,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Scene Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new scene entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
@@ -20,37 +20,10 @@
</div>
</div>
<!-- Batch Progress Bar -->
<div id="batch-progress-container" class="card mb-4 d-none">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 id="batch-status-text" class="mb-0">Batch Generating Scenes...</h5>
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
</div>
<div class="mb-3">
<small class="text-muted">Overall Batch Progress</small>
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<small id="current-scene-name" class="text-muted mb-1"></small>
<small id="current-step-progress" class="text-muted mb-1"></small>
</div>
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for scene in scenes %}
<div class="col" id="card-{{ scene.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/scene/{{ scene.slug }}'">
<div class="card h-100 character-card {% if request.args.get('highlight') == scene.slug %}border-success border-3 highlight-card{% endif %}" onclick="window.location.href='/scene/{{ scene.slug }}'">
<div class="img-container">
{% if scene.image_path %}
<img id="img-{{ scene.slug }}" src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}">
@@ -90,15 +63,29 @@
{% endblock %}
{% block scripts %}
<style>
.highlight-card {
animation: highlight-pulse 2s ease-in-out 3;
box-shadow: 0 0 20px rgba(25, 135, 84, 0.5) !important;
}
@keyframes highlight-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(25, 135, 84, 0.5); }
50% { box-shadow: 0 0 30px rgba(25, 135, 84, 0.8); }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const progressBar = document.getElementById('batch-progress-bar');
const taskProgressBar = document.getElementById('task-progress-bar');
const container = document.getElementById('batch-progress-container');
const statusText = document.getElementById('batch-status-text');
const nodeStatus = document.getElementById('batch-node-status');
const sceneNameText = document.getElementById('current-scene-name');
const stepProgressText = document.getElementById('current-step-progress');
@@ -129,17 +116,12 @@
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
// Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const scene of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/scene/${scene.slug}/generate`, {
method: 'POST',
@@ -154,12 +136,6 @@
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
@@ -175,23 +151,10 @@
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%';
progressBar.textContent = '100%';
statusText.textContent = 'Batch Scene Generation Complete!';
sceneNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
setTimeout(() => container.classList.add('d-none'), 5000);
alert(`Batch generation complete! ${jobs.length} scene images processed.`);
}
batchBtn.addEventListener('click', async () => {

View File

@@ -29,17 +29,6 @@
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
{% macro selection_checkbox(section, key, label, value) %}
<input class="form-check-input me-1" type="checkbox" name="include_field" value="{{ section }}::{{ key }}"
{% if preferences is not none %}
@@ -54,7 +43,7 @@
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
{% if style.image_path %}
<img src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}" class="img-fluid" data-preview-path="{{ style.image_path }}">
{% else %}
@@ -64,30 +53,38 @@
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_style_image', slug=style.slug) }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="image" class="form-label text-muted small">Update Cover Image</label>
<input class="form-control form-control-sm" type="file" id="image" name="image" required>
</div>
<button type="submit" class="btn btn-sm btn-outline-primary w-100 mb-2">Upload</button>
</form>
<hr>
{# Character Selector #}
<div class="mb-3">
<label for="character_select" class="form-label">Preview with Character</label>
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
<option value="">-- No Character (Style Only) --</option>
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
<option value="__random__" {% if selected_character == '__random__' or not selected_character %}selected{% endif %}>🎲 Random Character</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_character == char.slug %}selected{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
</div>
{# Additional Prompts #}
<div class="mb-2">
<label for="extra_positive" class="form-label">Additional Positive</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_positive" name="extra_positive" rows="2" placeholder="e.g. masterpiece, best quality" form="generate-form">{{ extra_positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="extra_negative" class="form-label">Additional Negative</label>
<textarea class="form-control form-control-sm font-monospace" id="extra_negative" name="extra_negative" rows="2" placeholder="e.g. blurry, low quality" form="generate-form">{{ extra_negative or '' }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">Seed</span>
<input type="number" class="form-control" id="seed-input" name="seed" form="generate-form" placeholder="Random" min="1" step="1">
<button type="button" class="btn btn-outline-secondary" id="seed-clear-btn" title="Clear (random)">&times;</button>
</div>
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form" data-requires="comfyui">Generate Preview</button>
<button type="button" class="btn btn-outline-info" id="endless-btn" onclick="window._endlessStart()" data-requires="comfyui">Endless</button>
<button type="button" class="btn btn-danger d-none" id="endless-stop-btn" onclick="window._endlessStop()">Stop Endless</button>
<small class="text-muted d-none" id="endless-counter"></small>
<button type="submit" form="generate-form" formaction="{{ url_for('save_style_defaults', slug=style.slug) }}" class="btn btn-sm btn-outline-secondary mt-2">Save Selection as Default</button>
</div>
</div>
@@ -109,7 +106,7 @@
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
@@ -130,6 +127,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('transfer_resource', category='styles', slug=style.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -143,6 +141,11 @@
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
{% if style.data.get('lora', {}).get('lora_name', '') != '' %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="strengths-tab" data-bs-toggle="tab" data-bs-target="#strengths-pane" type="button" role="tab">Strengths</button>
</li>
{% endif %}
</ul>
<div class="tab-content" id="detailTabContent">
@@ -215,7 +218,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">{{ existing_previews|length }} preview(s)</span>
<div class="d-flex gap-2">
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm">Generate All Characters</button>
<button type="button" id="generate-all-btn" class="btn btn-primary btn-sm" data-requires="comfyui">Generate All Characters</button>
<button type="button" id="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
@@ -229,10 +232,8 @@
{% for img in existing_previews %}
<div class="col">
<img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded"
class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
@@ -240,14 +241,18 @@
{% endfor %}
</div>
</div>
{% set sg_has_lora = style.data.get('lora', {}).get('lora_name', '') != '' %}
{% if sg_has_lora %}
<div class="tab-pane fade" id="strengths-pane" role="tabpanel">
{% set sg_entity = style %}
{% set sg_category = 'styles' %}
{% include 'partials/strengths_gallery.html' %}
</div>
{% endif %}
</div>
</div>
</div>
{% set sg_entity = style %}
{% set sg_category = 'styles' %}
{% set sg_has_lora = style.data.get('lora', {}).get('lora_name', '') != '' %}
{% include 'partials/strengths_gallery.html' %}
{% endblock %}
{% block scripts %}
@@ -320,10 +325,19 @@
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
@@ -344,8 +358,8 @@
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
@@ -408,10 +422,9 @@
});
initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
});
</script>
{% endblock %}

View File

@@ -4,8 +4,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Style Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new style entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
@@ -20,37 +20,10 @@
</div>
</div>
<!-- Batch Progress Bar -->
<div id="batch-progress-container" class="card mb-4 d-none">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 id="batch-status-text" class="mb-0">Batch Generating Styles...</h5>
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
</div>
<div class="mb-3">
<small class="text-muted">Overall Batch Progress</small>
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<small id="current-style-name" class="text-muted mb-1"></small>
<small id="current-step-progress" class="text-muted mb-1"></small>
</div>
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for style in styles %}
<div class="col" id="card-{{ style.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/style/{{ style.slug }}'">
<div class="card h-100 character-card {% if request.args.get('highlight') == style.slug %}border-success border-3 highlight-card{% endif %}" onclick="window.location.href='/style/{{ style.slug }}'">
<div class="img-container">
{% if style.image_path %}
<img id="img-{{ style.slug }}" src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}">
@@ -90,15 +63,29 @@
{% endblock %}
{% block scripts %}
<style>
.highlight-card {
animation: highlight-pulse 2s ease-in-out 3;
box-shadow: 0 0 20px rgba(25, 135, 84, 0.5) !important;
}
@keyframes highlight-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(25, 135, 84, 0.5); }
50% { box-shadow: 0 0 30px rgba(25, 135, 84, 0.8); }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const progressBar = document.getElementById('batch-progress-bar');
const taskProgressBar = document.getElementById('task-progress-bar');
const container = document.getElementById('batch-progress-container');
const statusText = document.getElementById('batch-status-text');
const nodeStatus = document.getElementById('batch-node-status');
const styleNameText = document.getElementById('current-style-name');
const stepProgressText = document.getElementById('current-step-progress');
@@ -129,17 +116,12 @@
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
// Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const style of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/style/${style.slug}/generate`, {
method: 'POST',
@@ -154,12 +136,6 @@
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
@@ -175,24 +151,11 @@
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%';
progressBar.textContent = '100%';
statusText.textContent = 'Batch Style Generation Complete!';
styleNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000);
alert(`Batch generation complete! ${jobs.length} style images processed.`);
}
batchBtn.addEventListener('click', async () => {

77
templates/transfer.html Normal file
View File

@@ -0,0 +1,77 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-warning text-dark">
<i class="bi bi-arrow-left-right"></i> Transfer Character
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle"></i>
<strong>Transferring:</strong> {{ character.name }} ({{ character.slug }})
<br>
<small>This will create a new entity in the target category. The original character will remain unchanged.</small>
</div>
<form action="{{ url_for('transfer_character', slug=character.slug) }}" method="post">
<div class="mb-3">
<label for="target_type" class="form-label">Target Category</label>
<select class="form-select" id="target_type" name="target_type" required>
<option value="">Select target category...</option>
<option value="look">Look (Character Appearance)</option>
<option value="outfit">Outfit (Clothing)</option>
<option value="action">Action (Pose/Activity)</option>
<option value="style">Style (Artistic Style)</option>
<option value="scene">Scene (Background/Environment)</option>
<option value="detailer">Detailer (Enhancement)</option>
</select>
<div class="form-text">Select where you want to transfer this character to.</div>
</div>
<div class="mb-3">
<label for="new_name" class="form-label">New Name</label>
<input type="text" class="form-control" id="new_name" name="new_name"
value="{{ character.name }}" required>
<div class="form-text">Name for the new entity in the target category.</div>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
<label class="form-check-label" for="use_llm">
<strong>Use AI to regenerate JSON</strong>
</label>
<div class="form-text">
<div class="alert alert-light border mt-2">
<i class="bi bi-lightbulb"></i>
<strong>AI Regeneration:</strong> The AI will analyze the character profile and create a new JSON structure appropriate for the target category.
<br>
<strong>Without AI:</strong> A blank template will be created with basic information transferred.
</div>
</div>
</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Important:</strong> The original JSON structure cannot be reused directly.
Each category has different required fields and structure.
AI regeneration ensures the new entity is properly formatted for its category.
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-warning btn-lg">
<i class="bi bi-arrow-left-right"></i> Transfer Character
</button>
<a href="{{ url_for('detail', slug=character.slug) }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,101 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-warning text-dark">
<i class="bi bi-arrow-left-right"></i> Transfer {{ category.rstrip('s').title() }}
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle"></i>
<strong>Transferring:</strong> {{ resource.name }} ({{ resource.slug }})
<br>
<small>This will create a new entity in the target category. The original {{ category.rstrip('s') }} will remain unchanged.</small>
</div>
<form action="{{ url_for('transfer_resource', category=category, slug=resource.slug) }}" method="post">
<div class="mb-3">
<label for="target_category" class="form-label">Target Category</label>
<select class="form-select" id="target_category" name="target_category" required>
<option value="">Select target category...</option>
{% for cat_id, cat_name in available_targets %}
<option value="{{ cat_id }}">{{ cat_name }}</option>
{% endfor %}
</select>
<div class="form-text">Select where you want to transfer this {{ category.rstrip('s') }} to.</div>
</div>
<div class="mb-3">
<label for="new_name" class="form-label">New Name</label>
<input type="text" class="form-control" id="new_name" name="new_name"
value="{{ resource.name }}" required>
<div class="form-text">Name for the new entity in the target category.</div>
</div>
<div class="mb-3">
<label for="new_id" class="form-label">New ID/Slug (optional)</label>
<input type="text" class="form-control" id="new_id" name="new_id"
value="{{ resource.slug }}">
<div class="form-text">Leave blank to auto-generate from the name.</div>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" value="on" checked>
<label class="form-check-label" for="use_llm">
<strong>Use AI to regenerate JSON</strong>
</label>
<div class="form-text">
<div class="alert alert-light border mt-2">
<i class="bi bi-lightbulb"></i>
<strong>AI Regeneration:</strong> The AI will analyze the {{ category.rstrip('s') }} profile and create a new JSON structure appropriate for the target category.
<br>
<strong>Without AI:</strong> A blank template will be created with basic information transferred.
</div>
</div>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="transfer_lora" name="transfer_lora" value="on" checked>
<label class="form-check-label" for="transfer_lora">
<strong>Transfer LoRA file</strong>
</label>
<div class="form-text">
Copy the associated LoRA file to the default directory for the target category.
</div>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="remove_original" name="remove_original" value="on" checked>
<label class="form-check-label" for="remove_original">
<strong>Remove original entry</strong>
</label>
<div class="form-text">
Delete the original {{ category.rstrip('s') }} entry after successful transfer.
</div>
</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Important:</strong> The original JSON structure cannot be reused directly.
Each category has different required fields and structure.
AI regeneration ensures the new entity is properly formatted for its category.
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-warning btn-lg">
<i class="bi bi-arrow-left-right"></i> Transfer {{ category.rstrip('s').title() }}
</button>
<a href="{{ url_for(cancel_route, slug=resource.slug) }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}