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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', '') %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
87
templates/looks/create.html
Normal file
87
templates/looks/create.html
Normal 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
353
templates/looks/detail.html
Normal 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
107
templates/looks/edit.html
Normal 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
252
templates/looks/index.html
Normal 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 %}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
441
templates/partials/strengths_gallery.html
Normal file
441
templates/partials/strengths_gallery.html
Normal 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>⚡ 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>🎯 Active range:
|
||||
<strong id="sg-saved-min">{{ sg_weight_min }}</strong>
|
||||
–
|
||||
<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">🎲</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">🗑</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">
|
||||
💾 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 — 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">↓ 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 ↑</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 %}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user