Add danbooru-mcp auto-start, git sync, status API endpoints, navbar status indicators, and LLM format retry

- app.py: add subprocess import; add _ensure_mcp_repo() to clone/pull
  danbooru-mcp from https://git.liveaodh.com/aodhan/danbooru-mcp into
  tools/danbooru-mcp/ at startup; add ensure_mcp_server_running() which
  calls _ensure_mcp_repo() then starts the Docker container if not running;
  add GET /api/status/comfyui and GET /api/status/mcp health endpoints;
  fix call_llm() to retry up to 3 times on unexpected response format
  (KeyError/IndexError), logging the raw response and prompting the LLM
  to respond with valid JSON before each retry
- templates/layout.html: add ComfyUI and MCP status dot indicators to
  navbar; add polling JS that checks both endpoints on load and every 30s
- static/style.css: add .service-status, .status-dot, .status-ok,
  .status-error, .status-checking styles and status-pulse keyframe animation
- .gitignore: add tools/ to exclude the cloned danbooru-mcp repo
This commit is contained in:
Aodhan Collins
2026-03-03 00:57:27 +00:00
parent 0b8802deb5
commit ae7ba961c1
1194 changed files with 17475 additions and 3268 deletions

View File

@@ -1,6 +1,34 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ action.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="20" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ action.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</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">
@@ -125,7 +153,7 @@
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">{{ action.name }}</h1>
<a href="{{ url_for('edit_action', slug=action.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
@@ -133,96 +161,148 @@
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Action</button>
</form>
</div>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<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('actions_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
</div>
</div>
<form id="generate-form" action="{{ url_for('generate_action_image', slug=action.slug) }}" method="post">
{# Action details section #}
{% set action_details = action.data.get('action', {}) %}
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Action Details</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, value in action_details.items() %}
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('action', key, key.replace('_', ' '), value) }}
{{ key.replace('_', ' ') }}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
</div>
{# Defaults (Pose/Expression Aggregates) #}
<div class="card mb-4 border-info">
<div class="card-header bg-info text-white">
<strong>Prompt Aggregates (Character Overrides)</strong>
</div>
<div class="card-body">
<div class="form-text mb-3">These fields will override character defaults when a character is selected.</div>
<dl class="row mb-0">
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('defaults', 'pose', 'Pose', True) }}
Pose (Combined)
</dt>
<dd class="col-sm-8 text-muted small">Aggregated from Action Pose fields</dd>
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('defaults', 'expression', 'Expression', True) }}
Expression (Combined)
</dt>
<dd class="col-sm-8 text-muted small">Aggregated from Action Expression fields</dd>
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('defaults', 'scene', 'Scene', action_details.get('additional')) }}
Scene (Additional)
</dt>
<dd class="col-sm-8 small">{{ action_details.get('additional') if action_details.get('additional') else '--' }}</dd>
</dl>
</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>
<li class="nav-item" role="presentation">
<button class="nav-link" id="previews-tab" data-bs-toggle="tab" data-bs-target="#previews-pane" type="button" role="tab">
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
</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_action_image', slug=action.slug) }}" method="post">
{# Action details section #}
{% set action_details = action.data.get('action', {}) %}
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Action Details</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, value in action_details.items() %}
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('action', key, key.replace('_', ' '), value) }}
{{ key.replace('_', ' ') }}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
</div>
{# Defaults (Pose/Expression Aggregates) #}
<div class="card mb-4 border-info">
<div class="card-header bg-info text-white">
<strong>Prompt Aggregates (Character Overrides)</strong>
</div>
<div class="card-body">
<div class="form-text mb-3">These fields will override character defaults when a character is selected.</div>
<dl class="row mb-0">
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('defaults', 'pose', 'Pose', True) }}
Pose (Combined)
</dt>
<dd class="col-sm-8 text-muted small">Aggregated from Action Pose fields</dd>
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('defaults', 'expression', 'Expression', True) }}
Expression (Combined)
</dt>
<dd class="col-sm-8 text-muted small">Aggregated from Action Expression fields</dd>
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('defaults', 'scene', 'Scene', action_details.get('additional')) }}
Scene (Additional)
</dt>
<dd class="col-sm-8 small">{{ action_details.get('additional') if action_details.get('additional') else '--' }}</dd>
</dl>
</div>
</div>
{# Character Identity/Wardrobe context when character is selected #}
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included based on the character's default selection.
</div>
</div>
{# LoRA section #}
{% set lora = action.data.get('lora', {}) %}
{% if lora %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>LoRA</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
{% if preferences is not none %}
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
{% elif action.default_fields is not none %}
{% if 'lora::lora_triggers' in action.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includeLora">Include Triggers</label>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, value in lora.items() %}
<dt class="col-sm-4 text-capitalize">{{ key.replace('_', ' ') }}</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
</form>
</div>
{# Character Identity/Wardrobe context when character is selected #}
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included based on the character's default selection.
</div>
</div>
{# LoRA section #}
{% set lora = action.data.get('lora', {}) %}
{% if lora %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>LoRA</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
{% if preferences is not none %}
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
{% elif action.default_fields is not none %}
{% if 'lora::lora_triggers' in action.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includeLora">Include Triggers</label>
<div class="tab-pane fade" id="previews-pane" role="tabpanel">
<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="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, value in lora.items() %}
<dt class="col-sm-4 text-capitalize">{{ key.replace('_', ' ') }}</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
<div id="batch-progress" class="mb-3 d-none">
<label id="batch-label" class="form-label small fw-semibold"></label>
<div class="progress" style="height: 8px;">
<div id="batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
</div>
</div>
<div id="preview-gallery" class="row row-cols-2 row-cols-md-3 g-2">
{% for img in existing_previews %}
<div class="col">
<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">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
{% endfor %}
</div>
</div>
{% endif %}
</form>
</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 %}
@@ -420,9 +500,113 @@
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
const finalizeBaseUrl = '/action/{{ action.slug }}/finalize_generation';
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
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"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
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);
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>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
fd.append('client_id', clientId);
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentPromptId = data.prompt_id;
await waitForCompletion(currentPromptId);
progressLabel.textContent = 'Saving image...';
const finalFD = new FormData();
finalFD.append('action', 'preview');
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD });
const finalData = await finalResp.json();
if (finalData.success) {
addToPreviewGallery(finalData.image_url, char.name);
previewImg.src = finalData.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
});
// JSON Editor
initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
});
// Image modal function
function showImage(src) {
document.getElementById('modalImage').src = src;
}

View File

@@ -52,6 +52,23 @@
<label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ action.data.lora.lora_triggers if action.data.lora else '' }}">
</div>
<div class="row mt-3">
<div class="col-md-6">
<label for="lora_lora_weight_min" class="form-label small text-muted">Min Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_min" name="lora_lora_weight_min"
value="{{ action.data.lora.get('lora_weight_min', '') if action.data.lora else '' }}"
placeholder="e.g. 0.8">
</div>
<div class="col-md-6">
<label for="lora_lora_weight_max" class="form-label small text-muted">Max Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_max" name="lora_lora_weight_max"
value="{{ action.data.lora.get('lora_weight_max', '') if action.data.lora else '' }}"
placeholder="e.g. 1.2">
</div>
</div>
<p class="text-muted small mt-1 mb-0">When Min ≠ Max, weight is randomised between them each generation.</p>
</div>
</div>

View File

@@ -3,19 +3,19 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Action Gallery</h2>
<div class="d-flex">
<button id="batch-generate-btn" class="btn btn-outline-success me-2">Generate Missing Covers</button>
<button id="regenerate-all-btn" class="btn btn-outline-danger me-2">Regenerate All Covers</button>
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="me-2">
<button type="submit" class="btn btn-primary">Bulk Create from LoRAs</button>
<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>
<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>
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="me-2">
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<button type="submit" class="btn btn-danger" onclick="return confirm('WARNING: This will re-run LLM generation for ALL action LoRAs, consuming significant API credits and overwriting ALL existing action metadata. Are you absolutely sure?')">Bulk Overwrite from LoRAs</button>
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all action metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL action LoRAs, consuming significant API credits and overwriting ALL existing action metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
<a href="{{ url_for('create_action') }}" class="btn btn-success me-2">Create New Action</a>
<form action="{{ url_for('rescan_actions') }}" method="post">
<button type="submit" class="btn btn-outline-primary">Rescan Action Files</button>
<a href="{{ url_for('create_action') }}" class="btn btn-sm btn-success">Create New Action</a>
<form action="{{ url_for('rescan_actions') }}" 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 action files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
@@ -62,14 +62,27 @@
</div>
<div class="card-body">
<h5 class="card-title text-center">{{ action.name }}</h5>
<p class="card-text small text-center text-muted">{{ action.data.tags | join(', ') }}</p>
<p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %}
{% if action.data.action is mapping %}
{% for v in action.data.action.values() %}
{% if v %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% if action.data.lora and action.data.lora.lora_triggers %}
{% set ns.parts = ns.parts + [action.data.lora.lora_triggers] %}
{% endif %}
{{ ns.parts | join(', ') }}
</p>
</div>
{% if action.data.lora and action.data.lora.lora_name %}
{% set lora_name = action.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<div class="card-footer text-center p-1">
<small class="text-muted" title="{{ action.data.lora.lora_name }}">{{ lora_name }}</small>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
{% if action.data.lora and action.data.lora.lora_name %}
{% set lora_name = action.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ action.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="actions" data-slug="{{ action.slug }}" data-name="{{ action.name | e }}">🗑</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -1,6 +1,34 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ ckpt.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="20" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ ckpt.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
@@ -97,78 +125,128 @@
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">{{ ckpt.name }}</h1>
</div>
<a href="{{ url_for('checkpoints_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<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('checkpoints_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
</div>
</div>
<form id="generate-form" action="{{ url_for('generate_checkpoint_image', slug=ckpt.slug) }}" method="post">
{% set d = ckpt.data or {} %}
<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>
<li class="nav-item" role="presentation">
<button class="nav-link" id="previews-tab" data-bs-toggle="tab" data-bs-target="#previews-pane" type="button" role="tab">
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
</ul>
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Checkpoint Info</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Name</dt>
<dd class="col-sm-9">{{ ckpt.name }}</dd>
<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_checkpoint_image', slug=ckpt.slug) }}" method="post">
{% set d = ckpt.data or {} %}
<dt class="col-sm-3">Family</dt>
<dd class="col-sm-9">{{ ckpt.checkpoint_path.split('/')[0] }}</dd>
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Checkpoint Info</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Name</dt>
<dd class="col-sm-9">{{ ckpt.name }}</dd>
<dt class="col-sm-3">File</dt>
<dd class="col-sm-9 text-muted small">
<code>{{ ckpt.checkpoint_path }}</code>
</dd>
</dl>
</div>
<dt class="col-sm-3">Family</dt>
<dd class="col-sm-9">{{ ckpt.checkpoint_path.split('/')[0] }}</dd>
<dt class="col-sm-3">File</dt>
<dd class="col-sm-9 text-muted small">
<code>{{ ckpt.checkpoint_path }}</code>
</dd>
</dl>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Generation Settings</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Steps</dt>
<dd class="col-sm-8">{{ d.get('steps', 25) }}</dd>
<dt class="col-sm-4">CFG</dt>
<dd class="col-sm-8">{{ d.get('cfg', 5) }}</dd>
<dt class="col-sm-4">Sampler</dt>
<dd class="col-sm-8"><code>{{ d.get('sampler_name', 'euler_ancestral') }}</code></dd>
<dt class="col-sm-4">Scheduler</dt>
<dd class="col-sm-8"><code>{{ d.get('scheduler', 'normal') }}</code></dd>
<dt class="col-sm-4">VAE</dt>
<dd class="col-sm-8">
{% if d.get('vae', 'integrated') == 'integrated' %}
<span class="badge bg-secondary">Integrated</span>
{% else %}
<code class="small">{{ d.get('vae') }}</code>
{% endif %}
</dd>
</dl>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Base Prompts</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Positive</dt>
<dd class="col-sm-8 small"><code>{{ d.get('base_positive', '') or '--' }}</code></dd>
<dt class="col-sm-4">Negative</dt>
<dd class="col-sm-8 small"><code>{{ d.get('base_negative', '') or '--' }}</code></dd>
</dl>
</div>
</div>
</form>
</div>
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Generation Settings</strong>
<div class="tab-pane fade" id="previews-pane" role="tabpanel">
<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="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Steps</dt>
<dd class="col-sm-8">{{ d.get('steps', 25) }}</dd>
<dt class="col-sm-4">CFG</dt>
<dd class="col-sm-8">{{ d.get('cfg', 5) }}</dd>
<dt class="col-sm-4">Sampler</dt>
<dd class="col-sm-8"><code>{{ d.get('sampler_name', 'euler_ancestral') }}</code></dd>
<dt class="col-sm-4">VAE</dt>
<dd class="col-sm-8">
{% if d.get('vae', 'integrated') == 'integrated' %}
<span class="badge bg-secondary">Integrated</span>
{% else %}
<code class="small">{{ d.get('vae') }}</code>
{% endif %}
</dd>
</dl>
<div id="batch-progress" class="mb-3 d-none">
<label id="batch-label" class="form-label small fw-semibold"></label>
<div class="progress" style="height: 8px;">
<div id="batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
</div>
</div>
<div id="preview-gallery" class="row row-cols-2 row-cols-md-3 g-2">
{% for img in existing_previews %}
<div class="col">
<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">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
{% endfor %}
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Base Prompts</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Positive</dt>
<dd class="col-sm-8 small"><code>{{ d.get('base_positive', '') or '--' }}</code></dd>
<dt class="col-sm-4">Negative</dt>
<dd class="col-sm-8 small"><code>{{ d.get('base_negative', '') or '--' }}</code></dd>
</dl>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -299,6 +377,110 @@
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
const finalizeBaseUrl = '/checkpoint/{{ ckpt.slug }}/finalize_generation';
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
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"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
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);
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>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
fd.append('client_id', clientId);
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentPromptId = data.prompt_id;
await waitForCompletion(currentPromptId);
progressLabel.textContent = 'Saving image...';
const finalFD = new FormData();
finalFD.append('action', 'preview');
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD });
const finalData = await finalResp.json();
if (finalData.success) {
addToPreviewGallery(finalData.image_url, char.name);
previewImg.src = finalData.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
});
// JSON Editor
initJsonEditor('{{ url_for("save_checkpoint_json", slug=ckpt.slug) }}');
});
function showImage(src) {

View File

@@ -3,21 +3,18 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Checkpoint Gallery</h2>
<div class="d-flex flex-wrap gap-2">
<button id="batch-generate-btn" class="btn btn-outline-success">Generate Missing Covers</button>
<button id="regenerate-all-btn" class="btn btn-outline-danger">Regenerate All Covers</button>
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post">
<button type="submit" class="btn btn-primary">Bulk Create from Checkpoints</button>
<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>
<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>
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post">
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<button type="submit" class="btn btn-danger"
onclick="return confirm('WARNING: This will re-run LLM generation for ALL checkpoints, consuming API credits and overwriting ALL existing metadata. Are you sure?')">
Bulk Overwrite from Checkpoints
</button>
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all checkpoint metadata (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL checkpoints, consuming API credits and overwriting ALL existing metadata. Are you sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
<form action="{{ url_for('rescan_checkpoints') }}" method="post">
<button type="submit" class="btn btn-outline-primary">Rescan Checkpoints</button>
<form action="{{ url_for('rescan_checkpoints') }}" 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 checkpoint files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
@@ -63,8 +60,10 @@
<div class="card-body">
<h5 class="card-title text-center">{{ ckpt.name }}</h5>
</div>
<div class="card-footer text-center p-1">
<div class="card-footer d-flex justify-content-between align-items-center p-1">
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
<button class="btn btn-sm btn-outline-danger py-0 px-1 ms-1 resource-delete-btn" title="Delete"
data-category="checkpoints" data-slug="{{ ckpt.slug }}" data-name="{{ ckpt.name | e }}">🗑</button>
</div>
</div>
</div>

View File

@@ -211,6 +211,11 @@
</form>
</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 %}

View File

@@ -1,6 +1,34 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ detailer.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="20" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ detailer.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</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">
@@ -57,7 +85,28 @@
{% endfor %}
</select>
</div>
{# Action Selector #}
<div class="mb-3">
<label for="action_select" class="form-label">Action / Pose LoRA</label>
<select class="form-select" id="action_select" name="action_slug" form="generate-form">
<option value="">-- No Action --</option>
{% for act in actions %}
<option value="{{ act.slug }}" {% if selected_action == act.slug %}selected{% endif %}>{{ act.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>
<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>
@@ -104,73 +153,125 @@
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">{{ detailer.name }}</h1>
<div class="mt-1">
<a href="{{ url_for('edit_detailer', slug=detailer.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Detailer</a>
</div>
</div>
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<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('detailers_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
</div>
</div>
<form id="generate-form" action="{{ url_for('generate_detailer_image', slug=detailer.slug) }}" method="post">
{# Detailer definition section #}
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Detailer Definition</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('special', 'tags', 'Prompt', detailer.data.prompt) }}
Prompt
</dt>
<dd class="col-sm-8">{{ detailer.data.prompt if detailer.data.prompt else '--' }}</dd>
</dl>
</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>
<li class="nav-item" role="presentation">
<button class="nav-link" id="previews-tab" data-bs-toggle="tab" data-bs-target="#previews-pane" type="button" role="tab">
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
</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_detailer_image', slug=detailer.slug) }}" method="post">
{# Detailer definition section #}
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Detailer Definition</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('special', 'tags', 'Prompt', detailer.data.prompt) }}
Prompt
</dt>
<dd class="col-sm-8">{{ detailer.data.prompt if detailer.data.prompt else '--' }}</dd>
</dl>
</div>
</div>
{# Character Identity/Wardrobe context when character is selected #}
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included.
</div>
</div>
{# LoRA section #}
{% set lora = detailer.data.get('lora', {}) %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>LoRA Integration</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
{% if preferences is not none %}
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
{% elif detailer.default_fields is not none %}
{% if 'lora::lora_triggers' in detailer.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includeLora">Include Triggers</label>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">LoRA Name</dt>
<dd class="col-sm-8 text-muted small">{{ lora.get('lora_name') if lora.get('lora_name') else '--' }}</dd>
<dt class="col-sm-4">Weight</dt>
<dd class="col-sm-8">{{ lora.get('lora_weight', 1.0) }}</dd>
<dt class="col-sm-4">Triggers</dt>
<dd class="col-sm-8 small"><code>{{ lora.get('lora_triggers') if lora.get('lora_triggers') else '--' }}</code></dd>
</dl>
</div>
</div>
</form>
</div>
{# Character Identity/Wardrobe context when character is selected #}
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included.
</div>
</div>
{# LoRA section #}
{% set lora = detailer.data.get('lora', {}) %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>LoRA Integration</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
{% if preferences is not none %}
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
{% elif detailer.default_fields is not none %}
{% if 'lora::lora_triggers' in detailer.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includeLora">Include Triggers</label>
<div class="tab-pane fade" id="previews-pane" role="tabpanel">
<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="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">LoRA Name</dt>
<dd class="col-sm-8 text-muted small">{{ lora.get('lora_name') if lora.get('lora_name') else '--' }}</dd>
<dt class="col-sm-4">Weight</dt>
<dd class="col-sm-8">{{ lora.get('lora_weight', 1.0) }}</dd>
<dt class="col-sm-4">Triggers</dt>
<dd class="col-sm-8 small"><code>{{ lora.get('lora_triggers') if lora.get('lora_triggers') else '--' }}</code></dd>
</dl>
<div id="batch-progress" class="mb-3 d-none">
<label id="batch-label" class="form-label small fw-semibold"></label>
<div class="progress" style="height: 8px;">
<div id="batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
</div>
</div>
<div id="preview-gallery" class="row row-cols-2 row-cols-md-3 g-2">
{% for img in existing_previews %}
<div class="col">
<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">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
{% endfor %}
</div>
</div>
</form>
</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 %}
@@ -184,6 +285,7 @@
const previewImg = document.getElementById('preview-img');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
const actionSelect = document.getElementById('action_select');
// Toggle character context info
charSelect.addEventListener('change', () => {
@@ -352,8 +454,116 @@
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
const finalizeBaseUrl = '/detailer/{{ detailer.slug }}/finalize_generation';
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
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"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
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);
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>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action_slug', actionSelect.value);
fd.append('extra_positive', document.getElementById('extra_positive').value);
fd.append('extra_negative', document.getElementById('extra_negative').value);
fd.append('action', 'preview');
fd.append('client_id', clientId);
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentPromptId = data.prompt_id;
await waitForCompletion(currentPromptId);
progressLabel.textContent = 'Saving image...';
const finalFD = new FormData();
finalFD.append('action', 'preview');
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD });
const finalData = await finalResp.json();
if (finalData.success) {
addToPreviewGallery(finalData.image_url, char.name);
previewImg.src = finalData.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
});
// JSON Editor
initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}

View File

@@ -44,6 +44,23 @@
<label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ detailer.data.lora.lora_triggers if detailer.data.lora else '' }}">
</div>
<div class="row mt-3">
<div class="col-md-6">
<label for="lora_lora_weight_min" class="form-label small text-muted">Min Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_min" name="lora_lora_weight_min"
value="{{ detailer.data.lora.get('lora_weight_min', '') if detailer.data.lora else '' }}"
placeholder="e.g. 0.6">
</div>
<div class="col-md-6">
<label for="lora_lora_weight_max" class="form-label small text-muted">Max Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_max" name="lora_lora_weight_max"
value="{{ detailer.data.lora.get('lora_weight_max', '') if detailer.data.lora else '' }}"
placeholder="e.g. 1.0">
</div>
</div>
<p class="text-muted small mt-1 mb-0">When Min ≠ Max, weight is randomised between them each generation.</p>
</div>
</div>
@@ -53,8 +70,15 @@
<div class="card-body">
<div class="mb-3">
<label for="detailer_prompt" class="form-label">Prompt</label>
<input type="text" class="form-control" id="detailer_prompt" name="detailer_prompt" value="{{ detailer.data.prompt }}">
<div class="form-text">e.g., "glossy eyes, detailed irises"</div>
<input type="text" class="form-control" id="detailer_prompt" name="detailer_prompt"
value="{{ detailer.data.prompt or '' }}">
<div class="form-text">Comma-separated tags, e.g. "glossy eyes, detailed irises"</div>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Extra Tags</label>
<input type="text" class="form-control" id="tags" name="tags"
value="{{ detailer.data.tags | join(', ') if detailer.data.tags else '' }}">
<div class="form-text">Comma-separated extra tags appended to every generation.</div>
</div>
</div>
</div>

View File

@@ -3,19 +3,19 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Detailer Gallery</h2>
<div class="d-flex">
<button id="batch-generate-btn" class="btn btn-outline-success me-2">Generate Missing Covers</button>
<button id="regenerate-all-btn" class="btn btn-outline-danger me-2">Regenerate All Covers</button>
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="me-2">
<button type="submit" class="btn btn-primary">Bulk Create from LoRAs</button>
<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>
<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>
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="me-2">
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<button type="submit" class="btn btn-danger" onclick="return confirm('WARNING: This will re-run LLM generation for ALL detailer LoRAs, consuming significant API credits and overwriting ALL existing detailer metadata. Are you absolutely sure?')">Bulk Overwrite from LoRAs</button>
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all detailer metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL detailer LoRAs, consuming significant API credits and overwriting ALL existing detailer metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
<a href="{{ url_for('create_detailer') }}" class="btn btn-success me-2">Create New Detailer</a>
<form action="{{ url_for('rescan_detailers') }}" method="post">
<button type="submit" class="btn btn-outline-primary">Rescan Detailer Files</button>
<a href="{{ url_for('create_detailer') }}" class="btn btn-sm btn-success">Create New Detailer</a>
<form action="{{ url_for('rescan_detailers') }}" 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 detailer files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
@@ -63,15 +63,28 @@
<div class="card-body">
<h5 class="card-title text-center">{{ detailer.name }}</h5>
<p class="card-text small text-center text-muted">
{{ detailer.data.prompt }}
{% set ns = namespace(parts=[]) %}
{% if detailer.data.prompt is string %}
{% if detailer.data.prompt %}{% set ns.parts = ns.parts + [detailer.data.prompt] %}{% endif %}
{% elif detailer.data.prompt %}
{% for v in detailer.data.prompt %}
{% if v %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% if detailer.data.lora and detailer.data.lora.lora_triggers %}
{% set ns.parts = ns.parts + [detailer.data.lora.lora_triggers] %}
{% endif %}
{{ ns.parts | join(', ') }}
</p>
</div>
{% if detailer.data.lora and detailer.data.lora.lora_name %}
{% set lora_name = detailer.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<div class="card-footer text-center p-1">
<small class="text-muted" title="{{ detailer.data.lora.lora_name }}">{{ lora_name }}</small>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
{% if detailer.data.lora and detailer.data.lora.lora_name %}
{% set lora_name = detailer.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ detailer.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="detailers" data-slug="{{ detailer.slug }}" data-name="{{ detailer.name | e }}">🗑</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -34,9 +34,23 @@
<label for="lora_lora_name" class="form-label">LoRA Name</label>
<select class="form-select" id="lora_lora_name" name="lora_lora_name">
<option value="">None</option>
{% for lora in loras %}
<option value="{{ lora }}" {% if character.data.lora.lora_name == lora %}selected{% endif %}>{{ lora }}</option>
{% endfor %}
{% if char_looks %}
<optgroup label="— Looks ({{ character.name }}) —">
{% for look in char_looks %}
{% set look_lora = look.data.lora.lora_name if look.data.lora else '' %}
{% if look_lora %}
<option value="{{ look_lora }}" {% if character.data.lora.lora_name == look_lora %}selected{% endif %}>{{ look.name }} ({{ look_lora.split('/')[-1].replace('.safetensors','') }})</option>
{% endif %}
{% endfor %}
</optgroup>
<optgroup label="— All Looks LoRAs —">
{% else %}
<optgroup label="— Looks LoRAs —">
{% endif %}
{% for lora in loras %}
<option value="{{ lora }}" {% if character.data.lora.lora_name == lora %}selected{% endif %}>{{ lora }}</option>
{% endfor %}
</optgroup>
</select>
</div>
<div class="col-md-4">
@@ -48,6 +62,23 @@
<label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ character.data.lora.lora_triggers }}">
</div>
<div class="row mt-3">
<div class="col-md-6">
<label for="lora_lora_weight_min" class="form-label small text-muted">Min Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_min" name="lora_lora_weight_min"
value="{{ character.data.lora.get('lora_weight_min', '') if character.data.lora else '' }}"
placeholder="e.g. 0.7">
</div>
<div class="col-md-6">
<label for="lora_lora_weight_max" class="form-label small text-muted">Max Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_max" name="lora_lora_weight_max"
value="{{ character.data.lora.get('lora_weight_max', '') if character.data.lora else '' }}"
placeholder="e.g. 1.0">
</div>
</div>
<p class="text-muted small mt-1 mb-0">When Min ≠ Max, weight is randomised between them each generation.</p>
</div>
</div>

View File

@@ -1,49 +1,6 @@
{% extends "layout.html" %}
{% block content %}
<style>
.gallery-card { position: relative; overflow: hidden; border-radius: 8px; background: #1a1a1a; cursor: pointer; }
.gallery-card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; transition: transform 0.2s; }
.gallery-card:hover img { transform: scale(1.04); }
.gallery-card .overlay {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.82));
padding: 28px 8px 8px; opacity: 0; transition: opacity 0.2s;
}
.gallery-card:hover .overlay { opacity: 1; }
.gallery-card .cat-badge {
position: absolute; top: 6px; left: 6px;
font-size: 0.65rem; text-transform: uppercase; letter-spacing: .04em;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
}
@media (min-width: 768px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
@media (min-width: 1200px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
/* Lightbox */
#lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,.92); z-index:9999;
align-items:center; justify-content:center; flex-direction:column; }
#lightbox.active { display:flex; }
#lightbox-img-wrap { position:relative; }
#lightbox-img { max-width:90vw; max-height:80vh; object-fit:contain; border-radius:6px;
box-shadow:0 0 40px rgba(0,0,0,.8); cursor:zoom-in; display:block; }
#lightbox-meta { color:#eee; margin-top:10px; text-align:center; font-size:.85rem; }
#lightbox-hint { color:rgba(255,255,255,.45); font-size:.75rem; margin-top:3px; }
#lightbox-close { position:fixed; top:16px; right:20px; font-size:2rem; color:#fff; cursor:pointer; z-index:10000; line-height:1; }
#lightbox-prompt-btn { position:fixed; bottom:20px; right:20px; z-index:10000; }
/* Prompt modal meta grid */
.meta-grid { display:grid; grid-template-columns:auto 1fr; gap:4px 12px; font-size:.85rem; }
.meta-grid .meta-label { color:#6c757d; white-space:nowrap; font-weight:600; }
.meta-grid .meta-value { font-family:monospace; word-break:break-all; }
.lora-chip { display:inline-flex; align-items:center; gap:4px; background:#f0f0f0;
border-radius:4px; padding:2px 8px; font-size:.8rem; font-family:monospace; margin:2px; }
.lora-chip .lora-strength { color:#6c757d; }
</style>
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Gallery
<span class="text-muted fs-6 fw-normal ms-2">{{ total }} image{{ 's' if total != 1 else '' }}</span>
@@ -130,12 +87,13 @@
{% if images %}
<div class="gallery-grid mb-4">
{% set cat_colors = {
'characters': 'primary',
'actions': 'danger',
'outfits': 'success',
'scenes': 'info',
'styles': 'warning',
'detailers': 'secondary',
'characters': 'primary',
'actions': 'danger',
'outfits': 'success',
'scenes': 'info',
'styles': 'warning',
'detailers': 'secondary',
'checkpoints': 'dark',
} %}
{% for img in images %}
<div class="gallery-card"
@@ -162,11 +120,20 @@
<a href="{{ url_for('detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Open</a>
{% elif img.category == 'checkpoints' %}
<a href="{{ url_for('checkpoint_detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Open</a>
{% else %}
<a href="{{ url_for('generator') }}?{{ img.category[:-1] }}={{ img.slug }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Generator</a>
{% endif %}
<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 }})">
🗑
</button>
</div>
</div>
</div>
@@ -217,9 +184,10 @@
<div id="lightbox-meta"></div>
<div id="lightbox-hint">Click image to open full size · Esc to close</div>
</div>
<button id="lightbox-prompt-btn" class="btn btn-sm btn-light" onclick="event.stopPropagation(); lightboxShowPrompt()">
View Prompt
</button>
<div 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 -->
@@ -277,6 +245,24 @@
</div>
</div>
</div>
<!-- Delete confirmation modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Image</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-0">Delete <strong id="deleteItemName"></strong>? The image file will be removed.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
@@ -379,8 +365,12 @@ async function showPrompt(imgPath, name, category, slug) {
// Generator link
const genUrl = category === 'characters'
? `/character/${slug}`
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
document.getElementById('openGeneratorBtn').href = genUrl;
: category === 'checkpoints'
? `/checkpoint/${slug}`
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
const genBtn = document.getElementById('openGeneratorBtn');
genBtn.href = genUrl;
genBtn.textContent = (category === 'characters' || category === 'checkpoints') ? 'Open' : 'Open in Generator';
} catch (e) {
document.getElementById('promptPositive').value = 'Error loading metadata.';
} finally {
@@ -397,5 +387,46 @@ function copyField(id, btn) {
setTimeout(() => btn.textContent = orig, 1500);
});
}
// ---- Delete modal ----
let _deletePath = '';
let deleteModal;
document.addEventListener('DOMContentLoaded', () => {
deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
});
function openDeleteModal(path, name) {
_deletePath = path;
document.getElementById('deleteItemName').textContent = name;
deleteModal.show();
}
async function confirmDelete() {
deleteModal.hide();
try {
const res = await fetch('/gallery/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: _deletePath}),
});
const data = await res.json();
if (data.status === 'ok') {
const card = document.querySelector(`.gallery-card[data-path="${CSS.escape(_deletePath)}"]`);
if (card) card.remove();
const countEl = document.querySelector('h4 .text-muted');
if (countEl) {
const m = countEl.textContent.match(/(\d+)/);
if (m) {
const n = parseInt(m[1]) - 1;
countEl.textContent = ` ${n} image${n !== 1 ? 's' : ''}`;
}
}
} else {
alert('Delete failed: ' + (data.error || 'unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e);
}
}
</script>
{% endblock %}

View File

@@ -174,7 +174,7 @@
<div class="col-md-7">
<div class="card">
<div class="card-header bg-dark text-white">Result</div>
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px; background-color: #eee;" id="result-container">
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container">
{% if generated_image %}
<div class="img-container w-100 h-100">
<img src="{{ url_for('static', filename='uploads/' + generated_image) }}" alt="Generated Result" class="img-fluid w-100" id="result-img">
@@ -198,11 +198,6 @@
{% endblock %}
{% block scripts %}
<style>
.mix-item:hover { background-color: rgba(0,0,0,.04); }
.mix-item { user-select: none; }
#mixAccordion .accordion-button { font-size: .9rem; }
</style>
<script>
// --- Filtering ---
function filterMixCategory(input, listId) {

View File

@@ -3,11 +3,11 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Character Gallery</h2>
<div class="d-flex">
<button id="batch-generate-btn" class="btn btn-outline-success me-2">Generate Missing Covers</button>
<button id="regenerate-all-btn" class="btn btn-outline-danger me-2">Regenerate All Covers</button>
<form action="{{ url_for('rescan') }}" method="post">
<button type="submit" class="btn btn-outline-primary">Rescan Character Files</button>
<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>
<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>
@@ -17,7 +17,7 @@
<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 text-dark">Starting...</span>
<span id="batch-node-status" class="badge bg-info">Starting...</span>
</div>
<div class="mb-3">
@@ -54,7 +54,29 @@
</div>
<div class="card-body">
<h5 class="card-title text-center">{{ char.name }}</h5>
<p class="card-text small text-center text-muted">{{ char.data.tags | join(', ') }}</p>
<p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %}
{% for section_key in ['identity', 'defaults'] %}
{% if char.data[section_key] is mapping %}
{% for v in char.data[section_key].values() %}
{% if v %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% set wardrobe = char.data.get('wardrobe', {}) %}
{% if wardrobe %}
{% set outfit_data = wardrobe.get('default', wardrobe) %}
{% if outfit_data is mapping %}
{% for v in outfit_data.values() %}
{% if v and v is string %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% if char.data.lora and char.data.lora.lora_triggers %}
{% set ns.parts = ns.parts + [char.data.lora.lora_triggers] %}
{% endif %}
{{ ns.parts | join(', ') }}
</p>
</div>
{% if char.data.lora.lora_name %}
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}

View File

@@ -3,37 +3,58 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Character Browser</title>
<title>GAZE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background-color: #f8f9fa; }
.character-card { transition: transform 0.2s; cursor: pointer; }
.character-card:hover { transform: scale(1.02); }
.img-container { height: 300px; overflow: hidden; background-color: #dee2e6; display: flex; align-items: center; justify-content: center; }
.img-container img { width: 100%; height: 100%; object-fit: cover; }
.progress-bar { transition: width 0.4s ease-in-out; }
</style>
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-dark bg-dark mb-4">
<nav class="navbar navbar-dark mb-4">
<div class="container">
<a class="navbar-brand" href="/">Character Browser</a>
<div class="d-flex">
<a href="/" class="btn btn-outline-light me-2">Characters</a>
<a href="/outfits" class="btn btn-outline-light me-2">Outfits</a>
<a href="/actions" class="btn btn-outline-light me-2">Actions</a>
<a href="/styles" class="btn btn-outline-light me-2">Styles</a>
<a href="/scenes" class="btn btn-outline-light me-2">Scenes</a>
<a href="/detailers" class="btn btn-outline-light me-2">Detailers</a>
<a href="/checkpoints" class="btn btn-outline-light me-2">Checkpoints</a>
<a href="/create" class="btn btn-outline-success me-2">Create Character</a>
<a href="/generator" class="btn btn-outline-light me-2">Generator</a>
<a href="/gallery" class="btn btn-outline-light me-2">Gallery</a>
<a href="/settings" class="btn btn-outline-light">Settings</a>
<a class="navbar-brand d-flex align-items-center gap-2" href="/"><img src="{{ url_for('static', filename='icons/gaze-logo.png') }}" class="navbar-logo">GAZE</a>
<div class="d-flex align-items-center gap-1 flex-wrap">
<a href="/" class="btn btn-sm btn-outline-light">Characters</a>
<a href="/outfits" class="btn btn-sm btn-outline-light">Outfits</a>
<a href="/looks" class="btn btn-sm btn-outline-light">Looks</a>
<a href="/actions" class="btn btn-sm btn-outline-light">Actions</a>
<a href="/styles" class="btn btn-sm btn-outline-light">Styles</a>
<a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a>
<a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a>
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
<div class="vr mx-1 d-none d-lg-block"></div>
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
<a href="/gallery" class="btn btn-sm btn-outline-light">Gallery</a>
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div>
<!-- Service status indicators -->
<span id="status-comfyui" class="service-status" title="ComfyUI" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="ComfyUI: checking…">
<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 class="status-dot status-checking"></span>
<span class="status-label d-none d-xl-inline">MCP</span>
</span>
</div>
</div>
</nav>
<div class="default-checkpoint-bar border-bottom mb-4">
<div class="container d-flex align-items-center gap-2 py-2">
<small class="text-muted text-nowrap">Default checkpoint:</small>
<select id="defaultCheckpointSelect" class="form-select form-select-sm" style="max-width: 320px;">
<option value="">— workflow default —</option>
{% for ckpt in all_checkpoints %}
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
{% endfor %}
</select>
<small id="checkpointSaveStatus" class="text-muted" style="opacity:0;transition:opacity 0.5s">Saved</small>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
@@ -46,7 +67,278 @@
{% block content %}{% endblock %}
</div>
<!-- Resource delete modal (shared across category gallery pages) -->
<div class="modal fade" id="resourceDeleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete <span id="rdm-name"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-grid gap-2">
<button class="btn btn-outline-secondary text-start" onclick="confirmResourceDelete('soft')">
<strong>Soft delete</strong> — remove entry from this gallery<br>
<small class="text-muted">JSON data file deleted; LoRA/checkpoint file kept on disk</small>
</button>
<button class="btn btn-outline-danger text-start" onclick="confirmResourceDelete('hard')">
<strong>Hard delete</strong> — remove entry and all associated files<br>
<small class="text-muted">Deletes JSON data file and LoRA/checkpoint safetensors from disk. Irreversible.</small>
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</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', () => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
const ckptSelect = document.getElementById('defaultCheckpointSelect');
const saveStatus = document.getElementById('checkpointSaveStatus');
if (ckptSelect) {
ckptSelect.addEventListener('change', () => {
fetch('/set_default_checkpoint', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'checkpoint_path=' + encodeURIComponent(ckptSelect.value)
}).then(() => {
saveStatus.style.opacity = '1';
setTimeout(() => { saveStatus.style.opacity = '0'; }, 1500);
});
});
}
});
</script>
<script>
// ---- Resource delete modal (category galleries) ----
let _rdmCategory = '', _rdmSlug = '';
let resourceDeleteModal;
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('resourceDeleteModal');
if (el) resourceDeleteModal = new bootstrap.Modal(el);
// Attach listeners to resource delete buttons via data attrs (avoids inline onclick issues)
document.querySelectorAll('.resource-delete-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
openResourceDeleteModal(this.dataset.category, this.dataset.slug, this.dataset.name);
});
});
});
function openResourceDeleteModal(category, slug, name) {
_rdmCategory = category;
_rdmSlug = slug;
document.getElementById('rdm-name').textContent = name;
resourceDeleteModal.show();
}
async function confirmResourceDelete(mode) {
resourceDeleteModal.hide();
try {
const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mode}),
});
const data = await res.json();
if (data.status === 'ok') {
const card = document.getElementById(`card-${_rdmSlug}`);
if (card) card.remove();
} else {
alert('Delete failed: ' + (data.error || 'unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e);
}
}
function initJsonEditor(saveUrl) {
const jsonModal = document.getElementById('jsonEditorModal');
if (!jsonModal) return;
const textarea = document.getElementById('json-editor-textarea');
const errBox = document.getElementById('json-editor-error');
const simplePanel = document.getElementById('json-simple-panel');
const advancedPanel = document.getElementById('json-advanced-panel');
const simpleTab = document.getElementById('json-simple-tab');
const advancedTab = document.getElementById('json-advanced-tab');
let activeTab = 'simple';
function buildSimpleForm(data) {
simplePanel.innerHTML = '';
for (const [key, value] of Object.entries(data)) {
const row = document.createElement('div');
row.className = 'row mb-2 align-items-start';
const labelCol = document.createElement('div');
labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1';
labelCol.textContent = key.replace(/_/g, ' ');
const inputCol = document.createElement('div');
inputCol.className = 'col-sm-9';
let el;
if (typeof value === 'boolean') {
const wrap = document.createElement('div');
wrap.className = 'form-check mt-2';
el = document.createElement('input');
el.type = 'checkbox';
el.className = 'form-check-input';
el.checked = value;
el.dataset.dtype = 'boolean';
wrap.appendChild(el);
inputCol.appendChild(wrap);
} else if (typeof value === 'number') {
el = document.createElement('input');
el.type = 'number';
el.step = 'any';
el.className = 'form-control form-control-sm';
el.value = value;
el.dataset.dtype = 'number';
inputCol.appendChild(el);
} else if (typeof value === 'string') {
if (value.length > 80) {
el = document.createElement('textarea');
el.className = 'form-control form-control-sm';
el.rows = 3;
} else {
el = document.createElement('input');
el.type = 'text';
el.className = 'form-control form-control-sm';
}
el.value = value;
el.dataset.dtype = 'string';
inputCol.appendChild(el);
} else {
el = document.createElement('textarea');
el.className = 'form-control form-control-sm font-monospace';
const lines = JSON.stringify(value, null, 2).split('\n');
el.rows = Math.min(10, lines.length + 1);
el.value = JSON.stringify(value, null, 2);
el.dataset.dtype = 'json';
inputCol.appendChild(el);
}
el.dataset.key = key;
row.appendChild(labelCol);
row.appendChild(inputCol);
simplePanel.appendChild(row);
}
}
function readSimpleForm() {
const result = {};
simplePanel.querySelectorAll('[data-key]').forEach(el => {
const key = el.dataset.key;
const dtype = el.dataset.dtype;
if (dtype === 'boolean') result[key] = el.checked;
else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; }
else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } }
else result[key] = el.value;
});
return result;
}
simpleTab.addEventListener('click', () => {
errBox.classList.add('d-none');
let data;
try { data = JSON.parse(textarea.value); }
catch (e) { errBox.textContent = 'Cannot switch: invalid JSON — ' + e.message; errBox.classList.remove('d-none'); return; }
buildSimpleForm(data);
simplePanel.classList.remove('d-none');
advancedPanel.classList.add('d-none');
simpleTab.classList.add('active');
advancedTab.classList.remove('active');
activeTab = 'simple';
});
advancedTab.addEventListener('click', () => {
textarea.value = JSON.stringify(readSimpleForm(), null, 2);
advancedPanel.classList.remove('d-none');
simplePanel.classList.add('d-none');
advancedTab.classList.add('active');
simpleTab.classList.remove('active');
activeTab = 'advanced';
});
jsonModal.addEventListener('show.bs.modal', () => {
const raw = document.getElementById('json-raw-data').textContent;
let data;
try { data = JSON.parse(raw); } catch { data = {}; }
buildSimpleForm(data);
textarea.value = JSON.stringify(data, null, 2);
simplePanel.classList.remove('d-none');
advancedPanel.classList.add('d-none');
simpleTab.classList.add('active');
advancedTab.classList.remove('active');
activeTab = 'simple';
errBox.classList.add('d-none');
});
document.getElementById('json-save-btn').addEventListener('click', async () => {
errBox.classList.add('d-none');
let parsed;
if (activeTab === 'simple') {
parsed = readSimpleForm();
} else {
try { parsed = JSON.parse(textarea.value); }
catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; }
}
const fd = new FormData();
fd.append('json_data', JSON.stringify(parsed));
const resp = await fetch(saveUrl, { method: 'POST', body: fd });
const result = await resp.json();
if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); }
else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); }
});
}
</script>
<script>
// ---- 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' },
];
function setStatus(id, label, ok) {
const el = document.getElementById(id);
if (!el) return;
const dot = el.querySelector('.status-dot');
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
el.setAttribute('data-bs-title', tooltipText);
el.setAttribute('title', tooltipText);
// Refresh tooltip instance if already initialised
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) {
tip.setContent({ '.tooltip-inner': tooltipText });
}
}
async function pollService(svc) {
try {
const r = await fetch(svc.url, { cache: 'no-store' });
const data = await r.json();
setStatus(svc.id, svc.label, data.status === 'ok');
} catch {
setStatus(svc.id, svc.label, false);
}
}
function pollAll() {
services.forEach(pollService);
}
document.addEventListener('DOMContentLoaded', () => {
pollAll();
setInterval(pollAll, 30000); // re-check every 30 s
});
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,87 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Create New Look</h1>
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
<form action="{{ url_for('create_look') }}" method="post">
<div class="row">
<div class="col-md-8">
<!-- Basic Info -->
<div class="card mb-4">
<div class="card-header bg-dark text-white">Basic Information</div>
<div class="card-body">
<div class="mb-3">
<label for="name" class="form-label">Display Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<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 }}">{{ char.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (comma separated)</label>
<input type="text" class="form-control" id="tags" name="tags">
</div>
</div>
</div>
<!-- LoRA -->
<div class="card mb-4">
<div class="card-header bg-info text-white">LoRA Settings</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<label for="lora_lora_name" class="form-label">LoRA Name</label>
<select class="form-select" id="lora_lora_name" name="lora_lora_name">
<option value="">None</option>
{% for lora in loras %}
<option value="{{ lora }}">{{ lora }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label for="lora_lora_weight" class="form-label">Weight</label>
<input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight" value="0.8">
</div>
</div>
<div class="mt-3">
<label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers">
</div>
</div>
</div>
<!-- Prompts -->
<div class="card mb-4">
<div class="card-header bg-light"><strong>Prompts</strong></div>
<div class="card-body">
<div class="mb-3">
<label for="positive" class="form-label">Positive</label>
<textarea class="form-control" id="positive" name="positive" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="negative" class="form-label">Negative</label>
<textarea class="form-control" id="negative" name="negative" rows="2"></textarea>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-4">
<div class="card-body">
<button type="submit" class="btn btn-success w-100">Create Look</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

353
templates/looks/detail.html Normal file
View File

@@ -0,0 +1,353 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ look.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="20" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ look.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</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').src)">
{% if look.image_path %}
<img src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}" class="img-fluid">
{% else %}
<span class="text-muted">No Image Attached</span>
{% 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>
{% for char in characters %}
<option value="{{ char.slug }}"
{% if selected_character == char.character_id or selected_character == char.slug %}selected
{% elif not selected_character and look.character_id == char.character_id %}selected
{% endif %}>{{ char.name }}</option>
{% endfor %}
</select>
<div class="form-text">Defaults to the linked character.</div>
</div>
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
<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>
</div>
<div id="progress-container" class="mb-4 d-none">
<label id="progress-label" class="form-label">Generating...</label>
<div class="progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_look_cover_from_preview', slug=look.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
</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)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_look_cover_from_preview', slug=look.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</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)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<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"
{% if preferences is not none %}
{% 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>
</div>
</div>
<div class="card-body">
{% for tag in look.data.tags %}
<span class="badge bg-secondary">{{ tag }}</span>
{% else %}
<span class="text-muted">No tags</span>
{% endfor %}
</div>
</div>
</div>
<div class="col-md-8">
<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>
{% 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>
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
</div>
</div>
<form id="generate-form" action="{{ url_for('generate_look_image', slug=look.slug) }}" method="post">
{# Positive prompt #}
{% if look.data.positive %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>Positive Prompt</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="positive" id="includePositive" form="generate-form"
{% if preferences is not none %}
{% if 'positive' in preferences %}checked{% endif %}
{% elif look.default_fields is not none %}
{% if 'positive' in look.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includePositive">Include</label>
</div>
</div>
<div class="card-body">
<p class="mb-0">{{ look.data.positive }}</p>
</div>
</div>
{% endif %}
{# Negative prompt #}
{% if look.data.negative %}
<div class="card mb-4">
<div class="card-header bg-light"><strong>Negative Prompt</strong></div>
<div class="card-body">
<p class="mb-0 text-muted">{{ look.data.negative }}</p>
</div>
</div>
{% endif %}
{# LoRA section #}
{% set lora = look.data.get('lora', {}) %}
{% if lora %}
<div class="card mb-4">
<div class="card-header bg-light"><strong>LoRA</strong></div>
<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 %}
{% if 'lora::' + key in preferences %}checked{% endif %}
{% elif look.default_fields is not none %}
{% if 'lora::' + key in look.default_fields %}checked{% endif %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
</form>
</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 %}
<script>
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const clientId = 'look_detail_' + Math.random().toString(36).substring(2, 15);
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
const nodeNames = {
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing",
"4": "Loading Models", "16": "Look LoRA", "17": "Outfit LoRA",
"18": "Action LoRA", "19": "Style/Detailer LoRA",
"8": "Decoding Image", "9": "Saving Image"
};
let currentPromptId = null;
let resolveCompletion = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress' && msg.data.prompt_id === currentPromptId) {
const percent = Math.round((msg.data.value / msg.data.max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
} else if (msg.type === 'executing' && msg.data.prompt_id === currentPromptId) {
if (msg.data.node === null) {
if (resolveCompletion) resolveCompletion();
} else {
progressLabel.textContent = nodeNames[msg.data.node] || 'Processing...';
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => { clearInterval(pollInterval); resolve(); };
resolveCompletion = checkResolve;
const pollInterval = setInterval(async () => {
try {
const resp = await fetch(`/check_status/${promptId}`);
const data = await resp.json();
if (data.status === 'finished') checkResolve();
} catch (err) { console.error('Polling error:', err); }
}, 2000);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = 'Starting...';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentPromptId = data.prompt_id;
progressLabel.textContent = 'Queued...';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
await waitForCompletion(currentPromptId);
finalizeGeneration(currentPromptId);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
});
async function finalizeGeneration(promptId) {
progressLabel.textContent = 'Saving image...';
const url = `/look/{{ look.slug }}/finalize_generation/${promptId}`;
try {
const response = await fetch(url, {
method: 'POST', body: new FormData()
});
const data = await response.json();
if (data.success) {
previewImg.src = data.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
} else {
alert('Save failed: ' + data.error);
}
} catch (err) {
console.error(err);
alert('Finalize request failed');
} finally {
progressContainer.classList.add('d-none');
}
}
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');
</script>
{% endblock %}

107
templates/looks/edit.html Normal file
View File

@@ -0,0 +1,107 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Edit Look: {{ look.name }}</h1>
<a href="{{ url_for('look_detail', slug=look.slug) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
<form action="{{ url_for('edit_look', slug=look.slug) }}" method="post">
<div class="row">
<div class="col-md-8">
<!-- Basic Info -->
<div class="card mb-4">
<div class="card-header bg-dark text-white">Basic Information</div>
<div class="card-body">
<div class="mb-3">
<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>
<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>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (comma separated)</label>
<input type="text" class="form-control" id="tags" name="tags" value="{{ look.data.tags | join(', ') }}">
</div>
</div>
</div>
<!-- LoRA -->
<div class="card mb-4">
<div class="card-header bg-info text-white">LoRA Settings</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<label for="lora_lora_name" class="form-label">LoRA Name</label>
<select class="form-select" id="lora_lora_name" name="lora_lora_name">
<option value="">None</option>
{% for lora in loras %}
<option value="{{ lora }}" {% if look.data.lora and look.data.lora.lora_name == lora %}selected{% endif %}>{{ lora }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label for="lora_lora_weight" class="form-label">Weight</label>
<input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight"
value="{{ look.data.lora.lora_weight if look.data.lora else 1.0 }}">
</div>
</div>
<div class="mt-3">
<label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers"
value="{{ look.data.lora.lora_triggers if look.data.lora else '' }}">
</div>
<div class="row mt-3">
<div class="col-md-6">
<label for="lora_lora_weight_min" class="form-label small text-muted">Min Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_min" name="lora_lora_weight_min"
value="{{ look.data.lora.get('lora_weight_min', '') if look.data.lora else '' }}"
placeholder="e.g. 0.8">
</div>
<div class="col-md-6">
<label for="lora_lora_weight_max" class="form-label small text-muted">Max Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_max" name="lora_lora_weight_max"
value="{{ look.data.lora.get('lora_weight_max', '') if look.data.lora else '' }}"
placeholder="e.g. 1.0">
</div>
</div>
<p class="text-muted small mt-1 mb-0">When Min ≠ Max, weight is randomised between them each generation.</p>
</div>
</div>
<!-- Prompts -->
<div class="card mb-4">
<div class="card-header bg-light"><strong>Prompts</strong></div>
<div class="card-body">
<div class="mb-3">
<label for="positive" class="form-label">Positive</label>
<textarea class="form-control" id="positive" name="positive" rows="3">{{ look.data.positive or '' }}</textarea>
</div>
<div class="mb-3">
<label for="negative" class="form-label">Negative</label>
<textarea class="form-control" id="negative" name="negative" rows="2">{{ look.data.negative or '' }}</textarea>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-4">
<div class="card-body">
<button type="submit" class="btn btn-primary w-100">Save Changes</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

252
templates/looks/index.html Normal file
View File

@@ -0,0 +1,252 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Looks Gallery</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>
<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>
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all look metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL look LoRAs, consuming significant API credits and overwriting ALL existing look metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
<a href="{{ url_for('create_look') }}" class="btn btn-sm btn-success">Create New Look</a>
<form action="{{ url_for('rescan_looks') }}" 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 look 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 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-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
{% 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="img-container">
{% if look.image_path %}
<img id="img-{{ look.slug }}" src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}">
<span id="no-img-{{ look.slug }}" class="text-muted d-none">No Image</span>
{% else %}
<img id="img-{{ look.slug }}" src="" alt="{{ look.name }}" class="d-none">
<span id="no-img-{{ look.slug }}" class="text-muted">No Image</span>
{% endif %}
</div>
<div class="card-body">
<h5 class="card-title text-center">{{ look.name }}</h5>
{% if look.character_id %}
<p class="card-text small text-center text-info">{{ look.character_id.replace('_', ' ').title() }}</p>
{% endif %}
<p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %}
{% if look.data.positive %}{% set ns.parts = ns.parts + [look.data.positive] %}{% endif %}
{% if look.data.lora and look.data.lora.lora_triggers %}
{% set ns.parts = ns.parts + [look.data.lora.lora_triggers] %}
{% endif %}
{{ ns.parts | join(', ') }}
</p>
</div>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
{% if look.data.lora and look.data.lora.lora_name %}
{% set lora_name = look.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ look.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="looks" data-slug="{{ look.slug }}" data-name="{{ look.name | e }}">🗑</button>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<p class="text-muted">No looks found. Add JSON files to <code>data/looks/</code> or use the create-from-LoRAs button above.</p>
</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script>
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 itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'looks_batch_' + Math.random().toString(36).substring(2, 15);
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
const nodeNames = {
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing",
"4": "Loading Models", "16": "Look LoRA", "17": "Outfit LoRA",
"18": "Action LoRA", "19": "Style/Detailer LoRA",
"8": "Decoding", "9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const percent = Math.round((msg.data.value / msg.data.max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || 'Processing...';
stepProgressText.textContent = "";
if (nodeId !== "3") {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => { clearInterval(pollInterval); resolve(); };
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try {
const resp = await fetch(`/check_status/${promptId}`);
const data = await resp.json();
if (data.status === 'finished') checkResolve();
} catch (err) {}
}, 2000);
});
}
async function runBatch() {
const response = await fetch('/get_missing_looks');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert("No looks missing cover images.");
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
container.classList.remove('d-none');
let completed = 0;
for (const item of missing) {
const percent = Math.round((completed / missing.length) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating Looks: ${completed + 1} / ${missing.length}`;
itemNameText.textContent = `Current: ${item.name}`;
nodeStatus.textContent = "Queuing...";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try {
// Looks are self-contained — no character_slug passed
const genResp = await fetch(`/look/${item.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({
'action': 'replace',
'client_id': clientId
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
currentPromptId = genData.prompt_id;
await waitForCompletion(currentPromptId);
const finResp = await fetch(`/look/${item.slug}/finalize_generation/${currentPromptId}`, {
method: 'POST',
body: new URLSearchParams({ 'action': 'replace' })
});
const finData = await finResp.json();
if (finData.success) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = finData.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none');
}
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
completed++;
}
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);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_looks');
const data = await response.json();
if (data.missing.length === 0) { alert("No looks missing cover images."); return; }
if (!confirm(`Generate cover images for ${data.missing.length} looks?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm("This will unassign ALL current look cover images and generate new ones. Proceed?")) return;
const clearResp = await fetch('/clear_all_look_covers', { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script>
{% endblock %}

View File

@@ -1,6 +1,34 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ outfit.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="20" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ outfit.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</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">
@@ -114,7 +142,7 @@
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">{{ outfit.name }}</h1>
<a href="{{ url_for('edit_outfit', slug=outfit.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
@@ -122,64 +150,116 @@
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Outfit</button>
</form>
</div>
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<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('outfits_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
</div>
</div>
<form id="generate-form" action="{{ url_for('generate_outfit_image', slug=outfit.slug) }}" method="post">
{# Wardrobe section #}
{% set wardrobe = outfit.data.get('wardrobe', {}) %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>Wardrobe</strong>
<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>
<li class="nav-item" role="presentation">
<button class="nav-link" id="previews-tab" data-bs-toggle="tab" data-bs-target="#previews-pane" type="button" role="tab">
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
</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_outfit_image', slug=outfit.slug) }}" method="post">
{# Wardrobe section #}
{% set wardrobe = outfit.data.get('wardrobe', {}) %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>Wardrobe</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, value in wardrobe.items() %}
<dt class="col-sm-4 text-capitalize">
<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 %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
</div>
{# LoRA section #}
{% set lora = outfit.data.get('lora', {}) %}
{% if lora %}
<div class="card mb-4">
<div class="card-header bg-light"><strong>LoRA</strong></div>
<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 %}
{% if 'lora::' + key in preferences %}checked{% endif %}
{% elif outfit.default_fields is not none %}
{% if 'lora::' + key in outfit.default_fields %}checked{% endif %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
</form>
</div>
<div class="tab-pane fade" id="previews-pane" role="tabpanel">
<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="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, value in wardrobe.items() %}
<dt class="col-sm-4 text-capitalize">
<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 %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
<div id="batch-progress" class="mb-3 d-none">
<label id="batch-label" class="form-label small fw-semibold"></label>
<div class="progress" style="height: 8px;">
<div id="batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
</div>
</div>
<div id="preview-gallery" class="row row-cols-2 row-cols-md-3 g-2">
{% for img in existing_previews %}
<div class="col">
<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">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
{% endfor %}
</div>
</div>
{# LoRA section #}
{% set lora = outfit.data.get('lora', {}) %}
{% if lora %}
<div class="card mb-4">
<div class="card-header bg-light"><strong>LoRA</strong></div>
<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 %}
{% if 'lora::' + key in preferences %}checked{% endif %}
{% elif outfit.default_fields is not none %}
{% if 'lora::' + key in outfit.default_fields %}checked{% endif %}
{% else %}
{% if value %}checked{% endif %}
{% endif %}>
{{ key.replace('_', ' ') }}
</dt>
<dd class="col-sm-8">{{ value if value else '--' }}</dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
</form>
</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 %}
@@ -366,9 +446,104 @@
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
const finalizeBaseUrl = '/outfit/{{ outfit.slug }}/finalize_generation';
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
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"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
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);
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>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
fd.append('client_id', clientId);
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentPromptId = data.prompt_id;
await waitForCompletion(currentPromptId);
progressLabel.textContent = 'Saving image...';
const finalFD = new FormData();
finalFD.append('action', 'preview');
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD });
const finalData = await finalResp.json();
if (finalData.success) {
addToPreviewGallery(finalData.image_url, char.name);
previewImg.src = finalData.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
});
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');
});
// Image modal function
function showImage(src) {
document.getElementById('modalImage').src = src;
}

View File

@@ -52,6 +52,23 @@
<label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ outfit.data.lora.lora_triggers }}">
</div>
<div class="row mt-3">
<div class="col-md-6">
<label for="lora_lora_weight_min" class="form-label small text-muted">Min Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_min" name="lora_lora_weight_min"
value="{{ outfit.data.lora.get('lora_weight_min', '') if outfit.data.lora else '' }}"
placeholder="e.g. 0.6">
</div>
<div class="col-md-6">
<label for="lora_lora_weight_max" class="form-label small text-muted">Max Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_max" name="lora_lora_weight_max"
value="{{ outfit.data.lora.get('lora_weight_max', '') if outfit.data.lora else '' }}"
placeholder="e.g. 1.0">
</div>
</div>
<p class="text-muted small mt-1 mb-0">When Min ≠ Max, weight is randomised between them each generation.</p>
</div>
</div>

View File

@@ -3,12 +3,19 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Outfit Gallery</h2>
<div class="d-flex">
<button id="batch-generate-btn" class="btn btn-outline-success me-2">Generate Missing Covers</button>
<button id="regenerate-all-btn" class="btn btn-outline-danger me-2">Regenerate All Covers</button>
<a href="{{ url_for('create_outfit') }}" class="btn btn-success me-2">Create New Outfit</a>
<form action="{{ url_for('rescan_outfits') }}" method="post">
<button type="submit" class="btn btn-outline-primary">Rescan Outfit Files</button>
<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>
<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>
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all outfit metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL outfit LoRAs, consuming significant API credits and overwriting ALL existing outfit metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
<a href="{{ url_for('create_outfit') }}" class="btn btn-sm btn-success">Create New Outfit</a>
<form action="{{ url_for('rescan_outfits') }}" 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 outfit files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
@@ -55,14 +62,27 @@
</div>
<div class="card-body">
<h5 class="card-title text-center">{{ outfit.name }}</h5>
<p class="card-text small text-center text-muted">{{ outfit.data.tags | join(', ') }}</p>
<p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %}
{% if outfit.data.wardrobe is mapping %}
{% for v in outfit.data.wardrobe.values() %}
{% if v %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% if outfit.data.lora and outfit.data.lora.lora_triggers %}
{% set ns.parts = ns.parts + [outfit.data.lora.lora_triggers] %}
{% endif %}
{{ ns.parts | join(', ') }}
</p>
</div>
{% if outfit.data.lora and outfit.data.lora.lora_name %}
{% set lora_name = outfit.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<div class="card-footer text-center p-1">
<small class="text-muted" title="{{ outfit.data.lora.lora_name }}">{{ lora_name }}</small>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
{% if outfit.data.lora and outfit.data.lora.lora_name %}
{% set lora_name = outfit.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ outfit.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="outfits" data-slug="{{ outfit.slug }}" data-name="{{ outfit.name | e }}">🗑</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -0,0 +1,441 @@
{% if sg_has_lora %}
{# -----------------------------------------------------------------------
Strengths Gallery partial
Required context variables (set via {% set %} before {% include %}):
sg_entity — the entity model object
sg_category — URL category string, e.g. 'outfits', 'characters'
sg_has_lora — boolean: entity has a non-empty lora_name
----------------------------------------------------------------------- #}
{% set sg_lora = sg_entity.data.lora if sg_entity.data.lora else {} %}
{% set sg_weight_min = sg_lora.get('lora_weight_min', sg_lora.get('lora_weight', 0.0)) %}
{% set sg_weight_max = sg_lora.get('lora_weight_max', sg_lora.get('lora_weight', 1.0)) %}
<div class="card mt-4" id="sg-card">
<div class="card-header d-flex justify-content-between align-items-center"
style="background: linear-gradient(135deg,#1a1a3e,#2a1a4e); cursor:pointer;"
onclick="document.getElementById('sg-body').classList.toggle('d-none')">
<span>&#9889; Strengths Gallery
<small class="text-muted ms-2" style="font-size:.8em;">
— sweep this LoRA weight with a fixed seed
</small>
</span>
<span id="sg-badge" class="badge bg-secondary">0 images</span>
</div>
<div class="card-body" id="sg-body">
{# Saved range indicator #}
<div id="sg-saved-range" class="alert alert-secondary py-1 px-2 mb-2 d-flex align-items-center gap-2" style="font-size:.85em;">
<span>&#127919; Active range:
<strong id="sg-saved-min">{{ sg_weight_min }}</strong>
&ndash;
<strong id="sg-saved-max">{{ sg_weight_max }}</strong>
</span>
<span class="text-muted ms-auto fst-italic" id="sg-save-status"></span>
</div>
{# Config row #}
<div class="row g-2 align-items-end mb-3">
<div class="col-sm-2">
<label class="form-label small mb-1">Min Weight</label>
<input type="number" id="sg-min" class="form-control form-control-sm"
value="{{ sg_weight_min }}" step="0.05" min="-5" max="5">
</div>
<div class="col-sm-2">
<label class="form-label small mb-1">Max Weight</label>
<input type="number" id="sg-max" class="form-control form-control-sm"
value="{{ sg_weight_max }}" step="0.05" min="-5" max="5">
</div>
<div class="col-sm-2">
<label class="form-label small mb-1">Interval</label>
<input type="number" id="sg-interval" class="form-control form-control-sm"
value="0.05" step="0.01" min="0.01" max="1.0">
</div>
<div class="col-sm-3">
<label class="form-label small mb-1">Seed</label>
<div class="input-group input-group-sm">
<input type="number" id="sg-seed" class="form-control" placeholder="auto">
<button class="btn btn-outline-secondary" type="button"
onclick="sgRollSeed()" title="Random seed">&#127922;</button>
</div>
</div>
<div class="col-sm-1">
<label class="form-label small mb-1">Count</label>
<span id="sg-step-count" class="form-control form-control-sm bg-transparent border-0 text-muted ps-0">11</span>
</div>
<div class="col-sm-2 d-flex flex-column gap-1">
<div class="d-flex gap-1">
<button id="sg-btn-run" class="btn btn-sm btn-primary flex-grow-1"
onclick="sgStart()">Generate</button>
<button id="sg-btn-stop" class="btn btn-sm btn-warning d-none"
onclick="sgStop()">Stop</button>
<button id="sg-btn-clear" class="btn btn-sm btn-outline-danger"
onclick="sgClear()" title="Clear results">&#128465;</button>
</div>
<button id="sg-btn-save-range" class="btn btn-sm btn-outline-success w-100"
onclick="sgSaveRange()" title="Save current Min/Max as the randomisation range for this LoRA">
&#128190; Save Range
</button>
</div>
</div>
{# Progress #}
<div id="sg-progress" class="d-none mb-3">
<div class="progress" style="height:6px;">
<div id="sg-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated"
style="width:0%"></div>
</div>
<small class="text-muted" id="sg-progress-label">0 / 0 &mdash; weight: —</small>
</div>
{# Results grid #}
<div id="sg-grid" class="row g-2">
{# Populated by JS on load and after each generation step #}
</div>
</div>{# /card-body #}
</div>{# /card #}
<script>
(function () {
'use strict';
const SG_CAT = {{ sg_category | tojson }};
const SG_SLUG = {{ sg_entity.slug | tojson }};
const SG_WS = {{ COMFYUI_WS_URL | tojson }};
const SG_CLIENT_ID = 'sg_' + Math.random().toString(36).slice(2, 10);
let sgRunning = false;
let sgShouldStop = false;
// ---- helpers ----
function sgRollSeed() {
document.getElementById('sg-seed').value = Math.floor(Math.random() * 1e15);
}
function sgGetInterval() {
const v = parseFloat(document.getElementById('sg-interval').value);
if (isNaN(v) || v < 0.01) return 0.01;
if (v > 1.0) return 1.0;
return v;
}
function sgBuildSteps(min, max, interval) {
if (min > max) return [];
interval = interval || 0.1;
const scale = Math.round(1 / interval);
const steps = [];
let v = min;
while (v <= max + interval * 0.5) {
steps.push(Math.round(v * scale) / scale);
v = Math.round((v + interval) * scale) / scale;
}
return steps;
}
// ---- validation & visual feedback ----
function sgValidateBounds() {
const minEl = document.getElementById('sg-min');
const maxEl = document.getElementById('sg-max');
const min = parseFloat(minEl.value);
const max = parseFloat(maxEl.value);
const invalid = !isNaN(min) && !isNaN(max) && min > max;
minEl.classList.toggle('is-invalid', invalid);
maxEl.classList.toggle('is-invalid', invalid);
return !invalid;
}
function sgUpdateCount() {
const valid = sgValidateBounds();
const min = parseFloat(document.getElementById('sg-min').value) || 0;
const max = parseFloat(document.getElementById('sg-max').value) || 1;
const count = valid ? sgBuildSteps(min, max, sgGetInterval()).length : 0;
document.getElementById('sg-step-count').textContent = count;
}
// Clamp on blur: push the other bound to match rather than block the user mid-type
document.getElementById('sg-min').addEventListener('change', () => {
const minEl = document.getElementById('sg-min');
const maxEl = document.getElementById('sg-max');
const min = parseFloat(minEl.value), max = parseFloat(maxEl.value);
if (!isNaN(min) && !isNaN(max) && min > max) maxEl.value = min;
sgUpdateCount();
sgHighlightBounds();
});
document.getElementById('sg-max').addEventListener('change', () => {
const minEl = document.getElementById('sg-min');
const maxEl = document.getElementById('sg-max');
const min = parseFloat(minEl.value), max = parseFloat(maxEl.value);
if (!isNaN(min) && !isNaN(max) && max < min) minEl.value = max;
sgUpdateCount();
sgHighlightBounds();
});
document.getElementById('sg-min').addEventListener('input', () => { sgUpdateCount(); sgHighlightBounds(); });
document.getElementById('sg-max').addEventListener('input', () => { sgUpdateCount(); sgHighlightBounds(); });
document.getElementById('sg-interval').addEventListener('input', sgUpdateCount);
// ---- highlight matching min/max buttons ----
function sgHighlightBounds() {
const currentMin = parseFloat(document.getElementById('sg-min').value);
const currentMax = parseFloat(document.getElementById('sg-max').value);
document.querySelectorAll('#sg-grid .sg-thumb').forEach(thumb => {
const sv = parseFloat(thumb.dataset.sgStrength);
const minBtn = thumb.querySelector('[data-sg-role="min-btn"]');
const maxBtn = thumb.querySelector('[data-sg-role="max-btn"]');
if (minBtn) {
const active = sv === currentMin;
minBtn.classList.toggle('btn-primary', active);
minBtn.classList.toggle('btn-outline-primary', !active);
}
if (maxBtn) {
const active = sv === currentMax;
maxBtn.classList.toggle('btn-warning', active);
maxBtn.classList.toggle('btn-outline-warning', !active);
}
});
}
function sgUpdateBadge() {
const count = document.querySelectorAll('#sg-grid .sg-thumb').length;
document.getElementById('sg-badge').textContent = count + ' image' + (count !== 1 ? 's' : '');
}
function sgSetMin(v) {
const maxEl = document.getElementById('sg-max');
document.getElementById('sg-min').value = v;
if (v > parseFloat(maxEl.value)) maxEl.value = v;
sgUpdateCount();
sgHighlightBounds();
}
function sgSetMax(v) {
const minEl = document.getElementById('sg-min');
document.getElementById('sg-max').value = v;
if (v < parseFloat(minEl.value)) minEl.value = v;
sgUpdateCount();
sgHighlightBounds();
}
function sgAddImage(imageUrl, strengthValue) {
const grid = document.getElementById('sg-grid');
const col = document.createElement('div');
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')">
<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">
<button class="btn btn-outline-primary btn-sm flex-grow-1 py-0" data-sg-role="min-btn"
style="font-size:.7em;" onclick="sgSetMin(${strengthValue})" title="Set as Min weight">&#8595; Min</button>
<button class="btn btn-outline-warning btn-sm flex-grow-1 py-0" data-sg-role="max-btn"
style="font-size:.7em;" onclick="sgSetMax(${strengthValue})" title="Set as Max weight">Max &#8593;</button>
</div>
</div>
</div>`;
grid.appendChild(col);
sgUpdateBadge();
sgHighlightBounds();
}
// ---- WebSocket wait ----
function sgWaitForCompletion(promptId) {
return new Promise((resolve, reject) => {
let ws;
try {
ws = new WebSocket(`${SG_WS}?clientId=${SG_CLIENT_ID}`);
} catch (e) {
// Fall back to polling if WS unavailable
sgPollUntilDone(promptId).then(resolve).catch(reject);
return;
}
const timeout = setTimeout(() => {
ws.close();
sgPollUntilDone(promptId).then(resolve).catch(reject);
}, 120000);
ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
if (msg.type === 'executing' && msg.data && msg.data.prompt_id === promptId) {
if (msg.data.node === null) {
clearTimeout(timeout);
ws.close();
resolve();
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
sgPollUntilDone(promptId).then(resolve).catch(reject);
};
});
}
async function sgPollUntilDone(promptId) {
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 2000));
const r = await fetch(`/check_status/${promptId}`);
const d = await r.json();
if (d.status === 'complete' || d.status === 'finished' || d.done) return;
}
}
// ---- main flow ----
async function sgClearImages() {
await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/clear`, { method: 'POST' });
document.getElementById('sg-grid').innerHTML = '';
sgUpdateBadge();
}
async function sgStart() {
if (sgRunning) return;
const min = parseFloat(document.getElementById('sg-min').value);
const max = parseFloat(document.getElementById('sg-max').value);
let seed = parseInt(document.getElementById('sg-seed').value);
if (isNaN(seed)) {
seed = Math.floor(Math.random() * 1e15);
document.getElementById('sg-seed').value = seed;
}
if (!sgValidateBounds()) return;
const steps = sgBuildSteps(min, max, sgGetInterval());
if (!steps.length) return;
// Clear any previous set before starting a new one
await sgClearImages();
sgRunning = true;
sgShouldStop = false;
document.getElementById('sg-btn-run').classList.add('d-none');
document.getElementById('sg-btn-stop').classList.remove('d-none');
document.getElementById('sg-progress').classList.remove('d-none');
for (let i = 0; i < steps.length; i++) {
if (sgShouldStop) break;
const sv = steps[i];
const pct = Math.round(((i) / steps.length) * 100);
document.getElementById('sg-progress-bar').style.width = pct + '%';
document.getElementById('sg-progress-label').textContent =
`${i} / ${steps.length} \u2014 weight: ${sv}`;
try {
// Queue one generation
// Pick up the character currently selected on this detail page (if any)
const charSelect = document.getElementById('character_select');
const charSlug = charSelect ? charSelect.value : '';
const formData = new URLSearchParams({
strength_value: sv,
seed: seed,
client_id: SG_CLIENT_ID,
character_slug: charSlug,
});
const queueResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/generate`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData,
});
const queueData = await queueResp.json();
if (!queueData.prompt_id) throw new Error('No prompt_id returned');
await sgWaitForCompletion(queueData.prompt_id);
// Finalize
const finData = new URLSearchParams({ strength_value: sv, seed: seed });
const finResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/finalize/${queueData.prompt_id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: finData,
});
const finJson = await finResp.json();
if (finJson.success && finJson.image_url) {
sgAddImage(finJson.image_url, sv);
}
} catch (err) {
console.error('[Strengths] step error:', sv, err);
}
}
document.getElementById('sg-progress-bar').style.width = '100%';
document.getElementById('sg-progress-label').textContent =
`Done \u2014 ${steps.length} images generated`;
setTimeout(() => document.getElementById('sg-progress').classList.add('d-none'), 3000);
document.getElementById('sg-btn-stop').classList.add('d-none');
document.getElementById('sg-btn-run').classList.remove('d-none');
sgRunning = false;
}
function sgStop() {
sgShouldStop = true;
document.getElementById('sg-btn-stop').classList.add('d-none');
document.getElementById('sg-btn-run').classList.remove('d-none');
}
async function sgClear() {
if (!confirm('Clear all Strengths Gallery images for this item?')) return;
await sgClearImages();
}
async function sgSaveRange() {
const min = parseFloat(document.getElementById('sg-min').value);
const max = parseFloat(document.getElementById('sg-max').value);
if (isNaN(min) || isNaN(max)) {
alert('Set valid Min and Max values first.');
return;
}
const statusEl = document.getElementById('sg-save-status');
statusEl.textContent = 'Saving…';
try {
const body = new URLSearchParams({ min_weight: Math.min(min, max), max_weight: Math.max(min, max) });
const resp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/save_range`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const data = await resp.json();
if (data.success) {
document.getElementById('sg-saved-min').textContent = data.lora_weight_min;
document.getElementById('sg-saved-max').textContent = data.lora_weight_max;
statusEl.textContent = '✓ Saved';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else {
statusEl.textContent = '✗ ' + (data.error || 'Error');
}
} catch (e) {
statusEl.textContent = '✗ Network error';
}
}
// Expose functions to inline onclick handlers
window.sgStart = sgStart;
window.sgStop = sgStop;
window.sgClear = sgClear;
window.sgSaveRange = sgSaveRange;
window.sgRollSeed = sgRollSeed;
window.sgSetMin = sgSetMin;
window.sgSetMax = sgSetMax;
// ---- Load existing images on page load ----
fetch(`/strengths/${SG_CAT}/${SG_SLUG}/list`)
.then(r => r.json())
.then(data => {
(data.images || []).forEach(img => sgAddImage(img.url, img.strength));
sgUpdateCount();
sgHighlightBounds();
})
.catch(() => sgUpdateCount());
})();
</script>
{% endif %}

View File

@@ -1,6 +1,34 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ scene.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="20" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ scene.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</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">
@@ -104,7 +132,7 @@
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">{{ scene.name }}</h1>
<div class="mt-1">
@@ -115,81 +143,133 @@
</form>
</div>
</div>
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<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('scenes_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
</div>
</div>
<form id="generate-form" action="{{ url_for('generate_scene_image', slug=scene.slug) }}" method="post">
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Scene Definition</strong>
</div>
<div class="card-body">
<p class="text-muted">{{ scene.data.description or "No description provided." }}</p>
<hr>
<dl class="row mb-0">
{% set sdata = scene.data.get('scene', {}) %}
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('defaults', 'scene', 'Scene Tags', True) }}
Combined Scene Tags
</dt>
<dd class="col-sm-8">
{% set tags = [] %}
{% for key, val in sdata.items() %}
{% if val %}
{% if val is string %}
{% set _ = tags.append(val) %}
{% else %}
{% for v in val %}{% set _ = tags.append(v) %}{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{{ tags|join(', ') }}
</dd>
</dl>
</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>
<li class="nav-item" role="presentation">
<button class="nav-link" id="previews-tab" data-bs-toggle="tab" data-bs-target="#previews-pane" type="button" role="tab">
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
</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_scene_image', slug=scene.slug) }}" method="post">
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Scene Definition</strong>
</div>
<div class="card-body">
<p class="text-muted">{{ scene.data.description or "No description provided." }}</p>
<hr>
<dl class="row mb-0">
{% set sdata = scene.data.get('scene', {}) %}
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('defaults', 'scene', 'Scene Tags', True) }}
Combined Scene Tags
</dt>
<dd class="col-sm-8">
{% set tags = [] %}
{% for key, val in sdata.items() %}
{% if val %}
{% if val is string %}
{% set _ = tags.append(val) %}
{% else %}
{% for v in val %}{% set _ = tags.append(v) %}{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{{ tags|join(', ') }}
</dd>
</dl>
</div>
</div>
{# Character Identity/Wardrobe context when character is selected #}
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included.
</div>
</div>
{# LoRA section #}
{% set lora = scene.data.get('lora', {}) %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>LoRA Integration</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
{% if preferences is not none %}
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
{% elif scene.default_fields is not none %}
{% if 'lora::lora_triggers' in scene.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includeLora">Include Triggers</label>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">LoRA Name</dt>
<dd class="col-sm-8 text-muted small">{{ lora.get('lora_name') if lora.get('lora_name') else '--' }}</dd>
<dt class="col-sm-4">Weight</dt>
<dd class="col-sm-8">{{ lora.get('lora_weight', 1.0) }}</dd>
<dt class="col-sm-4">Triggers</dt>
<dd class="col-sm-8 small"><code>{{ lora.get('lora_triggers') if lora.get('lora_triggers') else '--' }}</code></dd>
</dl>
</div>
</div>
</form>
</div>
{# Character Identity/Wardrobe context when character is selected #}
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included.
</div>
</div>
{# LoRA section #}
{% set lora = scene.data.get('lora', {}) %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>LoRA Integration</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
{% if preferences is not none %}
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
{% elif scene.default_fields is not none %}
{% if 'lora::lora_triggers' in scene.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includeLora">Include Triggers</label>
<div class="tab-pane fade" id="previews-pane" role="tabpanel">
<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="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">LoRA Name</dt>
<dd class="col-sm-8 text-muted small">{{ lora.get('lora_name') if lora.get('lora_name') else '--' }}</dd>
<dt class="col-sm-4">Weight</dt>
<dd class="col-sm-8">{{ lora.get('lora_weight', 1.0) }}</dd>
<dt class="col-sm-4">Triggers</dt>
<dd class="col-sm-8 small"><code>{{ lora.get('lora_triggers') if lora.get('lora_triggers') else '--' }}</code></dd>
</dl>
<div id="batch-progress" class="mb-3 d-none">
<label id="batch-label" class="form-label small fw-semibold"></label>
<div class="progress" style="height: 8px;">
<div id="batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
</div>
</div>
<div id="preview-gallery" class="row row-cols-2 row-cols-md-3 g-2">
{% for img in existing_previews %}
<div class="col">
<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">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
{% endfor %}
</div>
</div>
</form>
</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 %}
@@ -372,9 +452,113 @@
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
const finalizeBaseUrl = '/scene/{{ scene.slug }}/finalize_generation';
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
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"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
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);
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>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
fd.append('client_id', clientId);
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentPromptId = data.prompt_id;
await waitForCompletion(currentPromptId);
progressLabel.textContent = 'Saving image...';
const finalFD = new FormData();
finalFD.append('action', 'preview');
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD });
const finalData = await finalResp.json();
if (finalData.success) {
addToPreviewGallery(finalData.image_url, char.name);
previewImg.src = finalData.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
});
// JSON Editor
initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
});
// Image modal function
function showImage(src) {
document.getElementById('modalImage').src = src;
}

View File

@@ -48,6 +48,23 @@
<label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ scene.data.lora.lora_triggers if scene.data.lora else '' }}">
</div>
<div class="row mt-3">
<div class="col-md-6">
<label for="lora_lora_weight_min" class="form-label small text-muted">Min Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_min" name="lora_lora_weight_min"
value="{{ scene.data.lora.get('lora_weight_min', '') if scene.data.lora else '' }}"
placeholder="e.g. 0.6">
</div>
<div class="col-md-6">
<label for="lora_lora_weight_max" class="form-label small text-muted">Max Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_max" name="lora_lora_weight_max"
value="{{ scene.data.lora.get('lora_weight_max', '') if scene.data.lora else '' }}"
placeholder="e.g. 1.0">
</div>
</div>
<p class="text-muted small mt-1 mb-0">When Min ≠ Max, weight is randomised between them each generation.</p>
</div>
</div>
@@ -85,6 +102,19 @@
</div>
</div>
<!-- Tags -->
<div class="card mb-4">
<div class="card-header bg-light"><strong>Tags</strong></div>
<div class="card-body">
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<input type="text" class="form-control" id="tags" name="tags"
value="{{ scene.data.tags | join(', ') if scene.data.tags else '' }}">
<div class="form-text">Comma-separated tags appended to every generation.</div>
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{{ url_for('scene_detail', slug=scene.slug) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button>

View File

@@ -3,19 +3,19 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Scene Gallery</h2>
<div class="d-flex">
<button id="batch-generate-btn" class="btn btn-outline-success me-2">Generate Missing Covers</button>
<button id="regenerate-all-btn" class="btn btn-outline-danger me-2">Regenerate All Covers</button>
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="me-2">
<button type="submit" class="btn btn-primary">Bulk Create from LoRAs</button>
<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>
<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>
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="me-2">
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<button type="submit" class="btn btn-danger" onclick="return confirm('WARNING: This will re-run LLM generation for ALL scene LoRAs, consuming significant API credits and overwriting ALL existing scene metadata. Are you absolutely sure?')">Bulk Overwrite from LoRAs</button>
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all scene metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL scene LoRAs, consuming significant API credits and overwriting ALL existing scene metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
<a href="{{ url_for('create_scene') }}" class="btn btn-success me-2">Create New Scene</a>
<form action="{{ url_for('rescan_scenes') }}" method="post">
<button type="submit" class="btn btn-outline-primary">Rescan Scene Files</button>
<a href="{{ url_for('create_scene') }}" class="btn btn-sm btn-success">Create New Scene</a>
<form action="{{ url_for('rescan_scenes') }}" 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 scene files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
@@ -63,15 +63,26 @@
<div class="card-body">
<h5 class="card-title text-center">{{ scene.name }}</h5>
<p class="card-text small text-center text-muted">
{{ scene.data.description or "No description" }}
{% set ns = namespace(parts=[]) %}
{% if scene.data.scene is mapping %}
{% for v in scene.data.scene.values() %}
{% if v %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% if scene.data.lora and scene.data.lora.lora_triggers %}
{% set ns.parts = ns.parts + [scene.data.lora.lora_triggers] %}
{% endif %}
{{ ns.parts | join(', ') }}
</p>
</div>
{% if scene.data.lora and scene.data.lora.lora_name %}
{% set lora_name = scene.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<div class="card-footer text-center p-1">
<small class="text-muted" title="{{ scene.data.lora.lora_name }}">{{ lora_name }}</small>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
{% if scene.data.lora and scene.data.lora.lora_name %}
{% set lora_name = scene.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ scene.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="scenes" data-slug="{{ scene.slug }}" data-name="{{ scene.name | e }}">🗑</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -1,6 +1,34 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ style.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="20" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ style.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</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">
@@ -104,7 +132,7 @@
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">{{ style.name }}</h1>
<div class="mt-1">
@@ -115,73 +143,125 @@
</form>
</div>
</div>
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<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('styles_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
</div>
</div>
<form id="generate-form" action="{{ url_for('generate_style_image', slug=style.slug) }}" method="post">
{# Style definition section #}
{% set sdata = style.data.get('style', {}) %}
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Style Definition</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('style', 'artist_name', 'Artist Name', sdata.get('artist_name')) }}
Artist Name
</dt>
<dd class="col-sm-8">by {{ sdata.get('artist_name') if sdata.get('artist_name') else '--' }}</dd>
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('style', 'artistic_style', 'Artistic Style', sdata.get('artistic_style')) }}
Artistic Style
</dt>
<dd class="col-sm-8">{{ sdata.get('artistic_style') if sdata.get('artistic_style') else '--' }}</dd>
</dl>
</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>
<li class="nav-item" role="presentation">
<button class="nav-link" id="previews-tab" data-bs-toggle="tab" data-bs-target="#previews-pane" type="button" role="tab">
Previews{% if existing_previews %} <span class="badge bg-secondary">{{ existing_previews|length }}</span>{% endif %}
</button>
</li>
</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_style_image', slug=style.slug) }}" method="post">
{# Style definition section #}
{% set sdata = style.data.get('style', {}) %}
<div class="card mb-4">
<div class="card-header bg-light">
<strong>Style Definition</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('style', 'artist_name', 'Artist Name', sdata.get('artist_name')) }}
Artist Name
</dt>
<dd class="col-sm-8">by {{ sdata.get('artist_name') if sdata.get('artist_name') else '--' }}</dd>
<dt class="col-sm-4 text-capitalize">
{{ selection_checkbox('style', 'artistic_style', 'Artistic Style', sdata.get('artistic_style')) }}
Artistic Style
</dt>
<dd class="col-sm-8">{{ sdata.get('artistic_style') if sdata.get('artistic_style') else '--' }}</dd>
</dl>
</div>
</div>
{# Character Identity/Wardrobe context when character is selected #}
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included.
</div>
</div>
{# LoRA section #}
{% set lora = style.data.get('lora', {}) %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>LoRA Integration</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
{% if preferences is not none %}
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
{% elif style.default_fields is not none %}
{% if 'lora::lora_triggers' in style.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includeLora">Include Triggers</label>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">LoRA Name</dt>
<dd class="col-sm-8 text-muted small">{{ lora.get('lora_name') if lora.get('lora_name') else '--' }}</dd>
<dt class="col-sm-4">Weight</dt>
<dd class="col-sm-8">{{ lora.get('lora_weight', 1.0) }}</dd>
<dt class="col-sm-4">Triggers</dt>
<dd class="col-sm-8 small"><code>{{ lora.get('lora_triggers') if lora.get('lora_triggers') else '--' }}</code></dd>
</dl>
</div>
</div>
</form>
</div>
{# Character Identity/Wardrobe context when character is selected #}
<div id="character-context" class="{% if not selected_character or selected_character == '__random__' %}d-none{% endif %}">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> When a character is selected, their identity and active wardrobe fields will be automatically included.
</div>
</div>
{# LoRA section #}
{% set lora = style.data.get('lora', {}) %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<strong>LoRA Integration</strong>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="lora::lora_triggers" id="includeLora"
{% if preferences is not none %}
{% if 'lora::lora_triggers' in preferences %}checked{% endif %}
{% elif style.default_fields is not none %}
{% if 'lora::lora_triggers' in style.default_fields %}checked{% endif %}
{% else %}
checked
{% endif %}>
<label class="form-check-label small" for="includeLora">Include Triggers</label>
<div class="tab-pane fade" id="previews-pane" role="tabpanel">
<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="stop-all-btn" class="btn btn-danger btn-sm d-none">Stop</button>
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">LoRA Name</dt>
<dd class="col-sm-8 text-muted small">{{ lora.get('lora_name') if lora.get('lora_name') else '--' }}</dd>
<dt class="col-sm-4">Weight</dt>
<dd class="col-sm-8">{{ lora.get('lora_weight', 1.0) }}</dd>
<dt class="col-sm-4">Triggers</dt>
<dd class="col-sm-8 small"><code>{{ lora.get('lora_triggers') if lora.get('lora_triggers') else '--' }}</code></dd>
</dl>
<div id="batch-progress" class="mb-3 d-none">
<label id="batch-label" class="form-label small fw-semibold"></label>
<div class="progress" style="height: 8px;">
<div id="batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
</div>
</div>
<div id="preview-gallery" class="row row-cols-2 row-cols-md-3 g-2">
{% for img in existing_previews %}
<div class="col">
<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">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
{% endfor %}
</div>
</div>
</form>
</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 %}
@@ -362,8 +442,113 @@
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
const finalizeBaseUrl = '/style/{{ style.slug }}/finalize_generation';
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
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"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
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);
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>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
fd.append('client_id', clientId);
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentPromptId = data.prompt_id;
await waitForCompletion(currentPromptId);
progressLabel.textContent = 'Saving image...';
const finalFD = new FormData();
finalFD.append('action', 'preview');
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD });
const finalData = await finalResp.json();
if (finalData.success) {
addToPreviewGallery(finalData.image_url, char.name);
previewImg.src = finalData.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
});
// JSON Editor
initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
});
function showImage(src) {
document.getElementById('modalImage').src = src;
}

View File

@@ -44,6 +44,23 @@
<label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ style.data.lora.lora_triggers if style.data.lora else '' }}">
</div>
<div class="row mt-3">
<div class="col-md-6">
<label for="lora_lora_weight_min" class="form-label small text-muted">Min Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_min" name="lora_lora_weight_min"
value="{{ style.data.lora.get('lora_weight_min', '') if style.data.lora else '' }}"
placeholder="e.g. 0.6">
</div>
<div class="col-md-6">
<label for="lora_lora_weight_max" class="form-label small text-muted">Max Weight <span class="text-warning">(randomised)</span></label>
<input type="number" step="0.05" min="-5" max="5" class="form-control form-control-sm"
id="lora_lora_weight_max" name="lora_lora_weight_max"
value="{{ style.data.lora.get('lora_weight_max', '') if style.data.lora else '' }}"
placeholder="e.g. 1.0">
</div>
</div>
<p class="text-muted small mt-1 mb-0">When Min ≠ Max, weight is randomised between them each generation.</p>
</div>
</div>

View File

@@ -3,19 +3,19 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Style Gallery</h2>
<div class="d-flex">
<button id="batch-generate-btn" class="btn btn-outline-success me-2">Generate Missing Covers</button>
<button id="regenerate-all-btn" class="btn btn-outline-danger me-2">Regenerate All Covers</button>
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="me-2">
<button type="submit" class="btn btn-primary">Bulk Create from LoRAs</button>
<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>
<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>
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="me-2">
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<button type="submit" class="btn btn-danger" onclick="return confirm('WARNING: This will re-run LLM generation for ALL style LoRAs, consuming significant API credits and overwriting ALL existing style metadata. Are you absolutely sure?')">Bulk Overwrite from LoRAs</button>
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all style metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL style LoRAs, consuming significant API credits and overwriting ALL existing style metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
</form>
<a href="{{ url_for('create_style') }}" class="btn btn-success me-2">Create New Style</a>
<form action="{{ url_for('rescan_styles') }}" method="post">
<button type="submit" class="btn btn-outline-primary">Rescan Style Files</button>
<a href="{{ url_for('create_style') }}" class="btn btn-sm btn-success">Create New Style</a>
<form action="{{ url_for('rescan_styles') }}" 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 style files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
@@ -63,15 +63,26 @@
<div class="card-body">
<h5 class="card-title text-center">{{ style.name }}</h5>
<p class="card-text small text-center text-muted">
{% if style.data.style.artist_name %}by {{ style.data.style.artist_name }}{% endif %}
{% set ns = namespace(parts=[]) %}
{% if style.data.style is mapping %}
{% for v in style.data.style.values() %}
{% if v %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% if style.data.lora and style.data.lora.lora_triggers %}
{% set ns.parts = ns.parts + [style.data.lora.lora_triggers] %}
{% endif %}
{{ ns.parts | join(', ') }}
</p>
</div>
{% if style.data.lora and style.data.lora.lora_name %}
{% set lora_name = style.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<div class="card-footer text-center p-1">
<small class="text-muted" title="{{ style.data.lora.lora_name }}">{{ lora_name }}</small>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
{% if style.data.lora and style.data.lora.lora_name %}
{% set lora_name = style.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ style.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="styles" data-slug="{{ style.slug }}" data-name="{{ style.name | e }}">🗑</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}