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:
@@ -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)">×</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 %}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)">×</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 %}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)">×</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?')">×</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 %}
|
||||
|
||||
@@ -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)">×</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 %}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 X–Y 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 %}
|
||||
|
||||
@@ -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)">×</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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;">
|
||||
‹
|
||||
</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;">
|
||||
›
|
||||
</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>
|
||||
|
||||
@@ -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)">×</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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)">×</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 %}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 & 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)">×</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 & 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>
|
||||
|
||||
@@ -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)">×</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 %}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)">×</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 %}
|
||||
|
||||
@@ -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
77
templates/transfer.html
Normal 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 %}
|
||||
101
templates/transfer_resource.html
Normal file
101
templates/transfer_resource.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user