Add Checkpoints Gallery with per-checkpoint generation settings
- New Checkpoint model (slug, name, checkpoint_path, data JSON, image_path) - sync_checkpoints() loads metadata from data/checkpoints/*.json and falls back to template defaults for models without a JSON file - _apply_checkpoint_settings() applies per-checkpoint steps, CFG, sampler, base positive/negative prompts, and VAE (with dynamic VAELoader node injection for non-integrated VAEs) to the ComfyUI workflow - Bulk Create from Checkpoints: scans Illustrious/Noob model directories, reads matching HTML files, uses LLM to populate metadata, falls back to template defaults when no HTML is present - Gallery index with batch cover generation and WebSocket progress bar - Detail page showing Generation Settings and Base Prompts cards - Checkpoints nav link added to layout - New data/prompts/checkpoint_system.txt LLM system prompt - Updated README with all current galleries and file structure - Also includes accumulated action/scene JSON updates, new actions, and other template/generator improvements from prior sessions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -250,23 +250,37 @@
|
||||
const clientId = 'action_detail_' + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// ComfyUI WebSocket
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character LoRA",
|
||||
"17": "Outfit LoRA",
|
||||
"18": "Action LoRA",
|
||||
"19": "Style/Detailer LoRA",
|
||||
"8": "Decoding Image",
|
||||
"9": "Saving Image"
|
||||
};
|
||||
|
||||
let currentPromptId = null;
|
||||
let currentAction = null;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (!currentPromptId) return;
|
||||
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
if (!currentPromptId) {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / max) * 100);
|
||||
@@ -274,10 +288,16 @@
|
||||
progressBar.textContent = `${percent}%`;
|
||||
}
|
||||
else if (msg.type === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
// Execution finished via WebSocket
|
||||
console.log('Finished via WebSocket');
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
const nodeName = nodeNames[nodeId] || `Processing...`;
|
||||
progressLabel.textContent = nodeName;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -344,6 +364,9 @@
|
||||
|
||||
currentPromptId = data.prompt_id;
|
||||
progressLabel.textContent = 'Queued...';
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = 'Queued';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
// Wait for completion (WebSocket or Polling)
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
<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>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="me-2">
|
||||
<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>
|
||||
</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>
|
||||
@@ -19,11 +23,27 @@
|
||||
<!-- Batch Progress Bar -->
|
||||
<div id="batch-progress-container" class="card mb-4 d-none">
|
||||
<div class="card-body">
|
||||
<h5 id="batch-status-text">Batch Generating Actions...</h5>
|
||||
<div class="progress mt-2" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="batch-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width: 0%">0%</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h5 id="batch-status-text" class="mb-0">Batch Generating Actions...</h5>
|
||||
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Overall Batch Progress</small>
|
||||
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
|
||||
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small id="current-item-name" class="text-muted mb-1"></small>
|
||||
<small id="current-step-progress" class="text-muted mb-1"></small>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
|
||||
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="current-item-name" class="small text-muted mt-2 mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,21 +82,58 @@
|
||||
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 = 'actions_batch_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character 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 === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
|
||||
if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -116,12 +173,16 @@
|
||||
|
||||
let completed = 0;
|
||||
for (const item of missing) {
|
||||
completed++;
|
||||
const percent = Math.round((completed / missing.length) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
statusText.textContent = `Batch Generating Actions: ${completed} / ${missing.length}`;
|
||||
statusText.textContent = `Batch Generating Actions: ${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 {
|
||||
// Random character for action preview
|
||||
@@ -157,10 +218,17 @@
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
statusText.textContent = "Batch Action 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);
|
||||
|
||||
308
templates/checkpoints/detail.html
Normal file
308
templates/checkpoints/detail.html
Normal file
@@ -0,0 +1,308 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Image Modal -->
|
||||
<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-transparent border-0">
|
||||
<div class="modal-body p-0 text-center">
|
||||
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;"
|
||||
data-bs-toggle="modal" data-bs-target="#imageModal"
|
||||
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
|
||||
{% if ckpt.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}" class="img-fluid">
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
|
||||
<span class="text-muted">No Image Attached</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('upload_checkpoint_image', slug=ckpt.slug) }}" method="post" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="image" class="form-label text-muted small">Update Cover Image</label>
|
||||
<input class="form-control form-control-sm" type="file" id="image" name="image" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary w-100 mb-2">Upload</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="character_select" class="form-label">Preview with Character</label>
|
||||
<select class="form-select" id="character_select" name="character_slug" form="generate-form">
|
||||
<option value="">-- No Character (Generic) --</option>
|
||||
<option value="__random__" {% if selected_character == '__random__' %}selected{% endif %}>🎲 Random Character</option>
|
||||
{% for char in characters %}
|
||||
<option value="{{ char.slug }}" {% if selected_character == char.slug %}selected{% endif %}>{{ char.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" name="action" value="preview" class="btn btn-success" form="generate-form">Generate Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="progress-container" class="mb-4 d-none">
|
||||
<label id="progress-label" class="form-label">Generating...</label>
|
||||
<div class="progress">
|
||||
<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 p-2">
|
||||
<small>Latest Preview</small>
|
||||
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.slug) }}" method="post" class="m-0">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">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 p-2">
|
||||
<small>Latest Preview</small>
|
||||
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.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>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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>
|
||||
|
||||
<form id="generate-form" action="{{ url_for('generate_checkpoint_image', slug=ckpt.slug) }}" method="post">
|
||||
{% set d = ckpt.data or {} %}
|
||||
|
||||
<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">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">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>
|
||||
{% 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 = 'checkpoint_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": "Character 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 === 'status') {
|
||||
if (!currentPromptId) {
|
||||
const q = msg.data.status.exec_info.queue_remaining;
|
||||
if (q > 0) progressLabel.textContent = `Queue position: ${q}`;
|
||||
}
|
||||
} else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const percent = Math.round((msg.data.value / msg.data.max) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
} else if (msg.type === 'executing') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
progressLabel.textContent = nodeNames[nodeId] || '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) {}
|
||||
}, 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);
|
||||
await 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 = `/checkpoint/{{ ckpt.slug }}/finalize_generation/${promptId}`;
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'preview');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST', body: 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) {
|
||||
if (src) document.getElementById('modalImage').src = src;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
227
templates/checkpoints/index.html
Normal file
227
templates/checkpoints/index.html
Normal file
@@ -0,0 +1,227 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% 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>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post">
|
||||
<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>
|
||||
</form>
|
||||
<form action="{{ url_for('rescan_checkpoints') }}" method="post">
|
||||
<button type="submit" class="btn btn-outline-primary">Rescan Checkpoints</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 Checkpoints...</h5>
|
||||
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Overall Batch Progress</small>
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small id="current-ckpt-name" class="text-muted mb-1"></small>
|
||||
<small id="current-step-progress" class="text-muted mb-1"></small>
|
||||
</div>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
|
||||
{% for ckpt in checkpoints %}
|
||||
<div class="col" id="card-{{ ckpt.slug }}">
|
||||
<div class="card h-100 character-card" onclick="window.location.href='/checkpoint/{{ ckpt.slug }}'">
|
||||
<div class="img-container">
|
||||
{% if ckpt.image_path %}
|
||||
<img id="img-{{ ckpt.slug }}" src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}">
|
||||
<span id="no-img-{{ ckpt.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ ckpt.slug }}" src="" alt="{{ ckpt.name }}" class="d-none">
|
||||
<span id="no-img-{{ ckpt.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ ckpt.name }}</h5>
|
||||
</div>
|
||||
<div class="card-footer text-center p-1">
|
||||
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</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 ckptNameText = document.getElementById('current-ckpt-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
const clientId = 'checkpoints_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": "Character 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_checkpoints');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert('No checkpoints missing cover images.');
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
container.classList.remove('d-none');
|
||||
|
||||
let completed = 0;
|
||||
for (const ckpt of missing) {
|
||||
const percent = Math.round((completed / missing.length) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`;
|
||||
ckptNameText.textContent = `Current: ${ckpt.name}`;
|
||||
nodeStatus.textContent = 'Queuing...';
|
||||
taskProgressBar.style.width = '100%';
|
||||
taskProgressBar.textContent = 'Queued';
|
||||
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ 'client_id': clientId, 'character_slug': '__random__' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
currentPromptId = genData.prompt_id;
|
||||
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
const finResp = await fetch(`/checkpoint/${ckpt.slug}/finalize_generation/${currentPromptId}`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ 'action': 'replace' })
|
||||
});
|
||||
const finData = await finResp.json();
|
||||
|
||||
if (finData.success) {
|
||||
const img = document.getElementById(`img-${ckpt.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${ckpt.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 ${ckpt.name}:`, err);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
statusText.textContent = 'Batch Generation Complete!';
|
||||
ckptNameText.textContent = '';
|
||||
nodeStatus.textContent = 'Done';
|
||||
stepProgressText.textContent = '';
|
||||
taskProgressBar.style.width = '0%';
|
||||
taskProgressBar.textContent = '';
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
setTimeout(() => { container.classList.add('d-none'); }, 5000);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_checkpoints');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) { alert('No checkpoints missing cover images.'); return; }
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} checkpoints?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm('This will unassign ALL current checkpoint cover images and generate new ones. Proceed?')) return;
|
||||
const clearResp = await fetch('/clear_all_checkpoint_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 %}
|
||||
@@ -227,23 +227,37 @@
|
||||
const clientId = 'detail_view_' + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// ComfyUI WebSocket
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character LoRA",
|
||||
"17": "Outfit LoRA",
|
||||
"18": "Action LoRA",
|
||||
"19": "Style/Detailer LoRA",
|
||||
"8": "Decoding Image",
|
||||
"9": "Saving Image"
|
||||
};
|
||||
|
||||
let currentPromptId = null;
|
||||
let currentAction = null;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (!currentPromptId) return;
|
||||
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
if (!currentPromptId) {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / max) * 100);
|
||||
@@ -251,10 +265,16 @@
|
||||
progressBar.textContent = `${percent}%`;
|
||||
}
|
||||
else if (msg.type === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
// Execution finished via WebSocket
|
||||
console.log('Finished via WebSocket');
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
const nodeName = nodeNames[nodeId] || `Processing...`;
|
||||
progressLabel.textContent = nodeName;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -321,6 +341,9 @@
|
||||
|
||||
currentPromptId = data.prompt_id;
|
||||
progressLabel.textContent = 'Queued...';
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = 'Queued';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
// Wait for completion (WebSocket or Polling)
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
@@ -198,23 +198,37 @@
|
||||
const clientId = 'detailer_detail_' + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// ComfyUI WebSocket
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character LoRA",
|
||||
"17": "Outfit LoRA",
|
||||
"18": "Action LoRA",
|
||||
"19": "Style/Detailer LoRA",
|
||||
"8": "Decoding Image",
|
||||
"9": "Saving Image"
|
||||
};
|
||||
|
||||
let currentPromptId = null;
|
||||
let currentAction = null;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (!currentPromptId) return;
|
||||
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
if (!currentPromptId) {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / max) * 100);
|
||||
@@ -222,9 +236,16 @@
|
||||
progressBar.textContent = `${percent}%`;
|
||||
}
|
||||
else if (msg.type === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
// Execution finished
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
// Execution finished via WebSocket
|
||||
console.log('Finished via WebSocket');
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
const nodeName = nodeNames[nodeId] || `Processing...`;
|
||||
progressLabel.textContent = nodeName;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -238,12 +259,13 @@
|
||||
};
|
||||
resolveCompletion = checkResolve;
|
||||
|
||||
// Fallback polling
|
||||
// Fallback polling in case WebSocket is blocked (403)
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/check_status/${promptId}`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'finished') {
|
||||
console.log('Finished via Polling');
|
||||
checkResolve();
|
||||
}
|
||||
} catch (err) { console.error('Polling error:', err); }
|
||||
@@ -283,6 +305,10 @@
|
||||
|
||||
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, currentAction);
|
||||
currentPromptId = null;
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
<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>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="me-2">
|
||||
<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>
|
||||
</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>
|
||||
@@ -19,11 +23,27 @@
|
||||
<!-- Batch Progress Bar -->
|
||||
<div id="batch-progress-container" class="card mb-4 d-none">
|
||||
<div class="card-body">
|
||||
<h5 id="batch-status-text">Batch Generating Detailers...</h5>
|
||||
<div class="progress mt-2" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="batch-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width: 0%">0%</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h5 id="batch-status-text" class="mb-0">Batch Generating Detailers...</h5>
|
||||
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Overall Batch Progress</small>
|
||||
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
|
||||
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small id="current-detailer-name" class="text-muted mb-1"></small>
|
||||
<small id="current-step-progress" class="text-muted mb-1"></small>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
|
||||
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="current-detailer-name" class="small text-muted mt-2 mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,21 +84,59 @@
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const progressBar = document.getElementById('batch-progress-bar');
|
||||
const taskProgressBar = document.getElementById('task-progress-bar');
|
||||
const container = document.getElementById('batch-progress-container');
|
||||
const statusText = document.getElementById('batch-status-text');
|
||||
const nodeStatus = document.getElementById('batch-node-status');
|
||||
const detailerNameText = document.getElementById('current-detailer-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
const clientId = 'detailers_batch_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character 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 === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
|
||||
if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -117,16 +175,20 @@
|
||||
container.classList.remove('d-none');
|
||||
|
||||
let completed = 0;
|
||||
for (const detailer of missing) {
|
||||
completed++;
|
||||
for (const item of missing) {
|
||||
const percent = Math.round((completed / missing.length) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
statusText.textContent = `Batch Generating Detailers: ${completed} / ${missing.length}`;
|
||||
detailerNameText.textContent = `Current: ${detailer.name}`;
|
||||
statusText.textContent = `Batch Generating Detailers: ${completed + 1} / ${missing.length}`;
|
||||
detailerNameText.textContent = `Current: ${item.name}`;
|
||||
nodeStatus.textContent = "Queuing...";
|
||||
|
||||
taskProgressBar.style.width = '100%';
|
||||
taskProgressBar.textContent = 'Queued';
|
||||
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/detailer/${detailer.slug}/generate`, {
|
||||
const genResp = await fetch(`/detailer/${item.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
'action': 'replace',
|
||||
@@ -140,15 +202,15 @@
|
||||
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
const finResp = await fetch(`/detailer/${detailer.slug}/finalize_generation/${currentPromptId}`, {
|
||||
const finResp = await fetch(`/detailer/${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-${detailer.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${detailer.slug}`);
|
||||
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');
|
||||
@@ -156,12 +218,19 @@
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${detailer.name}:`, err);
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
statusText.textContent = "Batch Detailer Generation Complete!";
|
||||
detailerNameText.textContent = "";
|
||||
nodeStatus.textContent = "Done";
|
||||
stepProgressText.textContent = "";
|
||||
taskProgressBar.style.width = '0%';
|
||||
taskProgressBar.textContent = '';
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
setTimeout(() => { container.classList.add('d-none'); }, 5000);
|
||||
|
||||
401
templates/gallery.html
Normal file
401
templates/gallery.html
Normal file
@@ -0,0 +1,401 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.gallery-card { position: relative; overflow: hidden; border-radius: 8px; background: #1a1a1a; cursor: pointer; }
|
||||
.gallery-card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; transition: transform 0.2s; }
|
||||
.gallery-card:hover img { transform: scale(1.04); }
|
||||
.gallery-card .overlay {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.82));
|
||||
padding: 28px 8px 8px; opacity: 0; transition: opacity 0.2s;
|
||||
}
|
||||
.gallery-card:hover .overlay { opacity: 1; }
|
||||
.gallery-card .cat-badge {
|
||||
position: absolute; top: 6px; left: 6px;
|
||||
font-size: 0.65rem; text-transform: uppercase; letter-spacing: .04em;
|
||||
}
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
@media (min-width: 768px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
|
||||
@media (min-width: 1200px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
|
||||
|
||||
/* Lightbox */
|
||||
#lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,.92); z-index:9999;
|
||||
align-items:center; justify-content:center; flex-direction:column; }
|
||||
#lightbox.active { display:flex; }
|
||||
#lightbox-img-wrap { position:relative; }
|
||||
#lightbox-img { max-width:90vw; max-height:80vh; object-fit:contain; border-radius:6px;
|
||||
box-shadow:0 0 40px rgba(0,0,0,.8); cursor:zoom-in; display:block; }
|
||||
#lightbox-meta { color:#eee; margin-top:10px; text-align:center; font-size:.85rem; }
|
||||
#lightbox-hint { color:rgba(255,255,255,.45); font-size:.75rem; margin-top:3px; }
|
||||
#lightbox-close { position:fixed; top:16px; right:20px; font-size:2rem; color:#fff; cursor:pointer; z-index:10000; line-height:1; }
|
||||
#lightbox-prompt-btn { position:fixed; bottom:20px; right:20px; z-index:10000; }
|
||||
|
||||
/* Prompt modal meta grid */
|
||||
.meta-grid { display:grid; grid-template-columns:auto 1fr; gap:4px 12px; font-size:.85rem; }
|
||||
.meta-grid .meta-label { color:#6c757d; white-space:nowrap; font-weight:600; }
|
||||
.meta-grid .meta-value { font-family:monospace; word-break:break-all; }
|
||||
.lora-chip { display:inline-flex; align-items:center; gap:4px; background:#f0f0f0;
|
||||
border-radius:4px; padding:2px 8px; font-size:.8rem; font-family:monospace; margin:2px; }
|
||||
.lora-chip .lora-strength { color:#6c757d; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h4 class="mb-0">Gallery
|
||||
<span class="text-muted fs-6 fw-normal ms-2">{{ total }} image{{ 's' if total != 1 else '' }}</span>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" action="{{ url_for('gallery') }}" class="mb-3" id="filter-form">
|
||||
<div class="row g-2 align-items-end">
|
||||
|
||||
<!-- Category -->
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm mb-1">Category</label>
|
||||
<select name="category" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="all" {% if category == 'all' %}selected{% endif %}>All</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if category == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Item (only when a category is selected) -->
|
||||
{% if slug_options %}
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm mb-1">Item</label>
|
||||
<select name="slug" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">All items</option>
|
||||
{% for s, n in slug_options %}
|
||||
<option value="{{ s }}" {% if slug == s %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="hidden" name="slug" value="{{ slug }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm mb-1">Sort</label>
|
||||
<select name="sort" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Newest first</option>
|
||||
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Oldest first</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Per page -->
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm mb-1">Per page</label>
|
||||
<select name="per_page" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
{% for n in [24, 48, 96] %}
|
||||
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Active filter chips -->
|
||||
<div class="col">
|
||||
{% if category != 'all' %}
|
||||
<span class="badge bg-primary me-1">
|
||||
{{ category | capitalize }}
|
||||
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if slug %}
|
||||
<span class="badge bg-secondary me-1">
|
||||
{{ slug }}
|
||||
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="page" value="1">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Showing X–Y of N -->
|
||||
{% if total > 0 %}
|
||||
<p class="text-muted small mb-2">
|
||||
Showing {{ (page - 1) * per_page + 1 }}–{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Grid -->
|
||||
{% if images %}
|
||||
<div class="gallery-grid mb-4">
|
||||
{% set cat_colors = {
|
||||
'characters': 'primary',
|
||||
'actions': 'danger',
|
||||
'outfits': 'success',
|
||||
'scenes': 'info',
|
||||
'styles': 'warning',
|
||||
'detailers': 'secondary',
|
||||
} %}
|
||||
{% for img in images %}
|
||||
<div class="gallery-card"
|
||||
data-category="{{ img.category }}"
|
||||
data-slug="{{ img.slug }}"
|
||||
data-name="{{ img.item_name }}"
|
||||
data-path="{{ img.path }}"
|
||||
data-src="{{ url_for('static', filename='uploads/' + img.path) }}"
|
||||
onclick="openLightbox(this)">
|
||||
<img src="{{ url_for('static', filename='uploads/' + img.path) }}"
|
||||
alt="{{ img.item_name }}"
|
||||
loading="lazy">
|
||||
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
|
||||
{{ img.category[:-1] if img.category.endswith('s') else img.category }}
|
||||
</span>
|
||||
<div class="overlay">
|
||||
<div class="text-white small fw-semibold text-truncate">{{ img.item_name }}</div>
|
||||
<div class="d-flex gap-1 mt-1">
|
||||
<button class="btn btn-sm btn-light py-0 px-2 flex-grow-1"
|
||||
onclick="event.stopPropagation(); showPrompt({{ img.path | tojson }}, {{ img.item_name | tojson }}, {{ img.category | tojson }}, {{ img.slug | tojson }})">
|
||||
Prompt
|
||||
</button>
|
||||
{% if img.category == 'characters' %}
|
||||
<a href="{{ url_for('detail', slug=img.slug) }}"
|
||||
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||
onclick="event.stopPropagation()">Open</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('generator') }}?{{ img.category[:-1] }}={{ img.slug }}"
|
||||
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||
onclick="event.stopPropagation()">Generator</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm flex-wrap justify-content-center">
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=page-1) }}">‹</a>
|
||||
</li>
|
||||
|
||||
{% set window = 3 %}
|
||||
{% set ns = namespace(last=0) %}
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == 1 or p == total_pages or (p >= page - window and p <= page + window) %}
|
||||
{% if ns.last and p > ns.last + 1 %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=p) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% set ns.last = p %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=page+1) }}">›</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<div class="fs-1 mb-2">🖼️</div>
|
||||
<p>No images found{% if category != 'all' %} in <strong>{{ category }}</strong>{% endif %}{% if slug %} for <strong>{{ slug }}</strong>{% endif %}.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Lightbox -->
|
||||
<div id="lightbox" onclick="closeLightbox()">
|
||||
<span id="lightbox-close" onclick="closeLightbox()">×</span>
|
||||
<div id="lightbox-img-wrap" onclick="event.stopPropagation()">
|
||||
<img id="lightbox-img" src="" alt="" onclick="openFullSize()">
|
||||
<div id="lightbox-meta"></div>
|
||||
<div id="lightbox-hint">Click image to open full size · Esc to close</div>
|
||||
</div>
|
||||
<button id="lightbox-prompt-btn" class="btn btn-sm btn-light" onclick="event.stopPropagation(); lightboxShowPrompt()">
|
||||
View Prompt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Prompt modal -->
|
||||
<div class="modal fade" id="promptModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="promptModalTitle">Generation Info</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="promptLoading" class="text-center py-4">
|
||||
<div class="spinner-border text-secondary"></div>
|
||||
</div>
|
||||
<div id="promptContent" class="d-none">
|
||||
|
||||
<!-- Prompts -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="form-label fw-semibold mb-0">Positive Prompt</label>
|
||||
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="copyField('promptPositive', this)">Copy</button>
|
||||
</div>
|
||||
<textarea id="promptPositive" class="form-control form-control-sm font-monospace" rows="5" readonly></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="negativeRow">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="form-label fw-semibold mb-0">Negative Prompt</label>
|
||||
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="copyField('promptNegative', this)">Copy</button>
|
||||
</div>
|
||||
<textarea id="promptNegative" class="form-control form-control-sm font-monospace" rows="2" readonly></textarea>
|
||||
</div>
|
||||
|
||||
<!-- LoRAs -->
|
||||
<div class="mb-3" id="lorasRow">
|
||||
<label class="form-label fw-semibold mb-1">LoRAs</label>
|
||||
<div id="lorasContainer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Generation params -->
|
||||
<div class="mb-2" id="paramsRow">
|
||||
<label class="form-label fw-semibold mb-1">Generation Parameters</label>
|
||||
<div class="meta-grid" id="metaGrid"></div>
|
||||
</div>
|
||||
|
||||
<div id="noMetaMsg" class="d-none">
|
||||
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a id="openGeneratorBtn" href="{{ url_for('generator') }}" class="btn btn-primary">Open in Generator</a>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ---- Lightbox state ----
|
||||
let _lightboxSrc = '';
|
||||
let _lightboxPath = '';
|
||||
let _lightboxCategory = '';
|
||||
let _lightboxSlug = '';
|
||||
let _lightboxName = '';
|
||||
|
||||
function openLightbox(card) {
|
||||
_lightboxSrc = card.dataset.src;
|
||||
_lightboxPath = card.dataset.path;
|
||||
_lightboxCategory = card.dataset.category;
|
||||
_lightboxSlug = card.dataset.slug;
|
||||
_lightboxName = card.dataset.name;
|
||||
|
||||
document.getElementById('lightbox-img').src = _lightboxSrc;
|
||||
document.getElementById('lightbox-meta').textContent = _lightboxName + ' · ' + _lightboxCategory;
|
||||
document.getElementById('lightbox').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function closeLightbox() {
|
||||
document.getElementById('lightbox').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
function openFullSize() {
|
||||
window.open(_lightboxSrc, '_blank');
|
||||
}
|
||||
function lightboxShowPrompt() {
|
||||
closeLightbox();
|
||||
showPrompt(_lightboxPath, _lightboxName, _lightboxCategory, _lightboxSlug);
|
||||
}
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
|
||||
|
||||
// ---- Prompt modal ----
|
||||
let promptModal;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
promptModal = new bootstrap.Modal(document.getElementById('promptModal'));
|
||||
});
|
||||
|
||||
async function showPrompt(imgPath, name, category, slug) {
|
||||
document.getElementById('promptModalTitle').textContent = name;
|
||||
document.getElementById('promptContent').classList.add('d-none');
|
||||
document.getElementById('promptLoading').classList.remove('d-none');
|
||||
promptModal.show();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/gallery/prompt-data?path=${encodeURIComponent(imgPath)}`);
|
||||
const data = await res.json();
|
||||
|
||||
// Positive prompt
|
||||
document.getElementById('promptPositive').value = data.positive || '';
|
||||
|
||||
// Negative prompt
|
||||
const neg = data.negative || '';
|
||||
document.getElementById('promptNegative').value = neg;
|
||||
document.getElementById('negativeRow').style.display = neg ? '' : 'none';
|
||||
|
||||
// LoRAs
|
||||
const loras = (data.loras || []).filter(l => l.name);
|
||||
const lorasContainer = document.getElementById('lorasContainer');
|
||||
lorasContainer.innerHTML = '';
|
||||
if (loras.length) {
|
||||
loras.forEach(l => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'lora-chip';
|
||||
// Show only the filename part of the path
|
||||
const shortName = l.name.split('/').pop().replace('.safetensors', '');
|
||||
chip.innerHTML = `${shortName} <span class="lora-strength">${Number(l.strength).toFixed(2)}</span>`;
|
||||
chip.title = l.name;
|
||||
lorasContainer.appendChild(chip);
|
||||
});
|
||||
}
|
||||
document.getElementById('lorasRow').style.display = loras.length ? '' : 'none';
|
||||
|
||||
// Generation params
|
||||
const params = [
|
||||
['Checkpoint', data.checkpoint ? data.checkpoint.split('/').pop() : null],
|
||||
['Seed', data.seed],
|
||||
['Steps', data.steps],
|
||||
['CFG', data.cfg],
|
||||
['Sampler', data.sampler],
|
||||
['Scheduler', data.scheduler],
|
||||
];
|
||||
const hasParams = params.some(([, v]) => v !== null && v !== undefined);
|
||||
const grid = document.getElementById('metaGrid');
|
||||
grid.innerHTML = '';
|
||||
params.forEach(([label, val]) => {
|
||||
if (val === null || val === undefined) return;
|
||||
const lEl = document.createElement('span'); lEl.className = 'meta-label'; lEl.textContent = label;
|
||||
const vEl = document.createElement('span'); vEl.className = 'meta-value'; vEl.textContent = val;
|
||||
grid.appendChild(lEl); grid.appendChild(vEl);
|
||||
});
|
||||
document.getElementById('paramsRow').style.display = hasParams ? '' : 'none';
|
||||
document.getElementById('noMetaMsg').classList.toggle('d-none',
|
||||
!!(data.positive || loras.length || hasParams));
|
||||
|
||||
// Generator link
|
||||
const genUrl = category === 'characters'
|
||||
? `/character/${slug}`
|
||||
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
|
||||
document.getElementById('openGeneratorBtn').href = genUrl;
|
||||
} catch (e) {
|
||||
document.getElementById('promptPositive').value = 'Error loading metadata.';
|
||||
} finally {
|
||||
document.getElementById('promptLoading').classList.add('d-none');
|
||||
document.getElementById('promptContent').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function copyField(id, btn) {
|
||||
const text = document.getElementById(id).value;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = orig, 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,12 +4,37 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<div id="progress-container" class="mb-3 d-none">
|
||||
<label id="progress-label" class="form-label">Generating...</label>
|
||||
<div class="progress" role="progressbar" aria-label="Generation Progress" 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>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">Generator Settings</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('generator') }}" method="post">
|
||||
<form id="generator-form" action="{{ url_for('generator') }}" method="post">
|
||||
|
||||
<!-- Controls bar -->
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
|
||||
<button type="submit" class="btn btn-primary" id="generate-btn">Generate</button>
|
||||
<input type="number" id="num-images" value="1" min="1" max="999"
|
||||
class="form-control form-control-sm" style="width:65px" title="Number of images">
|
||||
<button type="button" class="btn btn-outline-warning" id="endless-btn">Endless</button>
|
||||
<button type="button" class="btn btn-danger d-none" id="stop-btn">Stop</button>
|
||||
<div class="ms-auto form-check mb-0">
|
||||
<input type="checkbox" class="form-check-input" id="lucky-dip">
|
||||
<label class="form-check-label" for="lucky-dip">Lucky Dip</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Character -->
|
||||
<div class="mb-3">
|
||||
<label for="character" class="form-label">Character</label>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label for="character" class="form-label mb-0">Character</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-char-btn">Random</button>
|
||||
</div>
|
||||
<select class="form-select" id="character" name="character" required>
|
||||
<option value="" disabled {% if not selected_char %}selected{% endif %}>Select a character...</option>
|
||||
{% for char in characters %}
|
||||
@@ -17,9 +42,13 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Checkpoint -->
|
||||
<div class="mb-3">
|
||||
<label for="checkpoint" class="form-label">Checkpoint Model</label>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label for="checkpoint" class="form-label mb-0">Checkpoint Model</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button>
|
||||
</div>
|
||||
<select class="form-select" id="checkpoint" name="checkpoint" required>
|
||||
{% for ckpt in checkpoints %}
|
||||
<option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt }}</option>
|
||||
@@ -28,43 +57,435 @@
|
||||
<div class="form-text">Listing models from Illustrious/ folder</div>
|
||||
</div>
|
||||
|
||||
<!-- Mix & Match -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Mix & Match
|
||||
<small class="text-muted fw-normal ms-1">— first checked per category applies its LoRA</small>
|
||||
</label>
|
||||
<div class="accordion" id="mixAccordion">
|
||||
{% set mix_categories = [
|
||||
('Actions', 'action', actions, 'action_slugs'),
|
||||
('Outfits', 'outfit', outfits, 'outfit_slugs'),
|
||||
('Scenes', 'scene', scenes, 'scene_slugs'),
|
||||
('Styles', 'style', styles, 'style_slugs'),
|
||||
('Detailers', 'detailer', detailers, 'detailer_slugs'),
|
||||
] %}
|
||||
{% for cat_label, cat_key, cat_items, field_name in mix_categories %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed py-2" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#mix-{{ cat_key }}">
|
||||
{{ cat_label }}
|
||||
<span class="badge bg-secondary rounded-pill ms-2" id="badge-{{ cat_key }}">0</span>
|
||||
<span class="badge bg-light text-secondary border ms-2 px-2 py-1"
|
||||
style="cursor:pointer;font-size:.7rem;font-weight:normal"
|
||||
onclick="event.stopPropagation(); randomizeCategory('{{ field_name }}', '{{ cat_key }}')">Random</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="mix-{{ cat_key }}" class="accordion-collapse collapse">
|
||||
<div class="accordion-body p-2">
|
||||
<input type="text" class="form-control form-control-sm mb-2"
|
||||
placeholder="Search {{ cat_label | lower }}..."
|
||||
oninput="filterMixCategory(this, 'mixlist-{{ cat_key }}')">
|
||||
<div id="mixlist-{{ cat_key }}" style="max-height:220px;overflow-y:auto;">
|
||||
{% for item in cat_items %}
|
||||
<label class="mix-item d-flex align-items-center gap-2 px-2 py-1 rounded"
|
||||
data-name="{{ item.name | lower }}" style="cursor:pointer;">
|
||||
<input type="checkbox" class="form-check-input flex-shrink-0"
|
||||
name="{{ field_name }}" value="{{ item.slug }}"
|
||||
onchange="updateMixBadge('{{ cat_key }}', '{{ field_name }}')">
|
||||
{% if item.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + item.image_path) }}"
|
||||
class="rounded flex-shrink-0" style="width:32px;height:32px;object-fit:cover">
|
||||
{% else %}
|
||||
<span class="rounded bg-light flex-shrink-0 d-inline-flex align-items-center justify-content-center text-muted"
|
||||
style="width:32px;height:32px;font-size:9px;">N/A</span>
|
||||
{% endif %}
|
||||
<span class="small text-truncate">{{ item.name }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="text-muted small p-2 mb-0">No {{ cat_label | lower }} found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Resolution</label>
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary preset-btn" data-w="1024" data-h="1024">1:1</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1152" data-h="896">4:3 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="896" data-h="1152">4:3 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1344" data-h="768">16:9 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1344">16:9 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1280" data-h="800">16:10 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="800" data-h="1280">16:10 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1792" data-h="768">21:9 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1792">21:9 P</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="form-label mb-0 small fw-semibold">W</label>
|
||||
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
|
||||
value="1024" min="64" max="4096" step="64" style="width:88px">
|
||||
<span class="text-muted">×</span>
|
||||
<label class="form-label mb-0 small fw-semibold">H</label>
|
||||
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
|
||||
value="1024" min="64" max="4096" step="64" style="width:88px">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Preview -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="form-label mb-0">Prompt Preview</label>
|
||||
<div class="d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="build-preview-btn">Build</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="form-control form-control-sm font-monospace" id="prompt-preview"
|
||||
name="override_prompt" rows="5"
|
||||
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
|
||||
<div class="form-text" id="preview-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Additional prompts -->
|
||||
<div class="mb-3">
|
||||
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
|
||||
<textarea class="form-control" id="positive_prompt" name="positive_prompt" rows="3" placeholder="e.g. sitting in a cafe, drinking coffee, daylight"></textarea>
|
||||
<textarea class="form-control" id="positive_prompt" name="positive_prompt" rows="2" placeholder="e.g. sitting in a cafe, drinking coffee, daylight"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="negative_prompt" class="form-label">Additional Negative Prompt</label>
|
||||
<textarea class="form-control" id="negative_prompt" name="negative_prompt" rows="3" placeholder="e.g. bad hands, extra digits"></textarea>
|
||||
<textarea class="form-control" id="negative_prompt" name="negative_prompt" rows="2" placeholder="e.g. bad hands, extra digits"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">Generate</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px; background-color: #eee;" 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">
|
||||
<img src="{{ url_for('static', filename='uploads/' + generated_image) }}" alt="Generated Result" class="img-fluid w-100" id="result-img">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted">
|
||||
<div class="text-center text-muted" id="placeholder-text">
|
||||
<p>Select settings and click Generate</p>
|
||||
</div>
|
||||
<div class="img-container w-100 h-100 d-none">
|
||||
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if generated_image %}
|
||||
<div class="card-footer">
|
||||
<div class="card-footer d-none" id="result-footer">
|
||||
<small class="text-muted">Saved to character gallery</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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) {
|
||||
const query = input.value.toLowerCase();
|
||||
document.querySelectorAll(`#${listId} .mix-item`).forEach(el => {
|
||||
el.style.display = el.dataset.name.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function updateMixBadge(key, fieldName) {
|
||||
const count = document.querySelectorAll(`input[name="${fieldName}"]:checked`).length;
|
||||
const badge = document.getElementById(`badge-${key}`);
|
||||
badge.textContent = count;
|
||||
badge.className = count > 0
|
||||
? 'badge bg-primary rounded-pill ms-2'
|
||||
: 'badge bg-secondary rounded-pill ms-2';
|
||||
}
|
||||
|
||||
// --- Randomizers (global so inline onclick can call them) ---
|
||||
function randomizeCategory(fieldName, catKey) {
|
||||
const cbs = Array.from(document.querySelectorAll(`input[name="${fieldName}"]`));
|
||||
cbs.forEach(cb => cb.checked = false);
|
||||
if (cbs.length) cbs[Math.floor(Math.random() * cbs.length)].checked = true;
|
||||
updateMixBadge(catKey, fieldName);
|
||||
}
|
||||
|
||||
function applyLuckyDip() {
|
||||
const charOpts = Array.from(document.getElementById('character').options).filter(o => o.value);
|
||||
if (charOpts.length)
|
||||
document.getElementById('character').value = charOpts[Math.floor(Math.random() * charOpts.length)].value;
|
||||
|
||||
const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
||||
if (ckptOpts.length)
|
||||
document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
|
||||
|
||||
const presets = Array.from(document.querySelectorAll('.preset-btn'));
|
||||
if (presets.length) presets[Math.floor(Math.random() * presets.length)].click();
|
||||
|
||||
[['action_slugs', 'action'], ['outfit_slugs', 'outfit'], ['scene_slugs', 'scene'],
|
||||
['style_slugs', 'style'], ['detailer_slugs', 'detailer']].forEach(([field, key]) => {
|
||||
randomizeCategory(field, key);
|
||||
});
|
||||
|
||||
document.getElementById('prompt-preview').value = '';
|
||||
document.getElementById('preview-status').textContent = '';
|
||||
}
|
||||
|
||||
// --- Resolution presets ---
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.getElementById('res-width').value = btn.dataset.w;
|
||||
document.getElementById('res-height').value = btn.dataset.h;
|
||||
document.querySelectorAll('.preset-btn').forEach(b => {
|
||||
b.classList.remove('btn-secondary');
|
||||
b.classList.add('btn-outline-secondary');
|
||||
});
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
btn.classList.add('btn-secondary');
|
||||
});
|
||||
});
|
||||
|
||||
['res-width', 'res-height'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
document.querySelectorAll('.preset-btn').forEach(b => {
|
||||
b.classList.remove('btn-secondary');
|
||||
b.classList.add('btn-outline-secondary');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Prompt preview ---
|
||||
async function buildPromptPreview() {
|
||||
const charVal = document.getElementById('character').value;
|
||||
const status = document.getElementById('preview-status');
|
||||
if (!charVal) { status.textContent = 'Select a character first.'; return; }
|
||||
|
||||
status.textContent = 'Building...';
|
||||
const formData = new FormData(document.getElementById('generator-form'));
|
||||
try {
|
||||
const resp = await fetch('/generator/preview_prompt', { method: 'POST', body: formData });
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
status.textContent = 'Error: ' + data.error;
|
||||
} else {
|
||||
document.getElementById('prompt-preview').value = data.prompt;
|
||||
status.textContent = 'Auto-built — edit freely, or Clear to let the server rebuild on generate.';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = 'Request failed.';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
|
||||
document.getElementById('clear-preview-btn').addEventListener('click', () => {
|
||||
document.getElementById('prompt-preview').value = '';
|
||||
document.getElementById('preview-status').textContent = '';
|
||||
});
|
||||
|
||||
// --- Main generation logic ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('generator-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressCont = document.getElementById('progress-container');
|
||||
const progressLbl = document.getElementById('progress-label');
|
||||
const generateBtn = document.getElementById('generate-btn');
|
||||
const endlessBtn = document.getElementById('endless-btn');
|
||||
const stopBtn = document.getElementById('stop-btn');
|
||||
const numInput = document.getElementById('num-images');
|
||||
const resultImg = document.getElementById('result-img');
|
||||
const placeholder = document.getElementById('placeholder-text');
|
||||
const resultFooter = document.getElementById('result-footer');
|
||||
|
||||
const clientId = 'generator_view_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling", "4": "Loading Models", "8": "Decoding Image", "9": "Saving Image",
|
||||
"11": "Face Detailing", "13": "Hand Detailing",
|
||||
"16": "Character LoRA", "17": "Outfit LoRA", "18": "Action LoRA", "19": "Style/Detailer LoRA"
|
||||
};
|
||||
|
||||
let currentPromptId = null;
|
||||
let resolveCompletion = null;
|
||||
let stopRequested = false;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'status') {
|
||||
if (!currentPromptId) {
|
||||
const q = msg.data.status.exec_info.queue_remaining;
|
||||
if (q > 0) progressLbl.textContent = `Queue position: ${q}`;
|
||||
}
|
||||
} else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const pct = Math.round((msg.data.value / msg.data.max) * 100);
|
||||
progressBar.style.width = `${pct}%`;
|
||||
progressBar.textContent = `${pct}%`;
|
||||
} else if (msg.type === 'executing') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
if (msg.data.node === null) {
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
progressLbl.textContent = nodeNames[msg.data.node] || 'Processing...';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForCompletion(promptId) {
|
||||
return new Promise((resolve) => {
|
||||
const done = () => { clearInterval(poll); resolve(); };
|
||||
resolveCompletion = done;
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch(`/check_status/${promptId}`);
|
||||
if ((await r.json()).status === 'finished') done();
|
||||
} catch (_) {}
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
async function finalizeGeneration(slug, promptId) {
|
||||
progressLbl.textContent = 'Saving image...';
|
||||
try {
|
||||
const r = await fetch(`/generator/finalize/${slug}/${promptId}`, { method: 'POST' });
|
||||
const data = await r.json();
|
||||
if (data.success) {
|
||||
resultImg.src = data.image_url;
|
||||
resultImg.parentElement.classList.remove('d-none');
|
||||
if (placeholder) placeholder.classList.add('d-none');
|
||||
resultFooter.classList.remove('d-none');
|
||||
} else {
|
||||
alert('Save failed: ' + data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Finalize request failed');
|
||||
}
|
||||
}
|
||||
|
||||
function setGeneratingState(active) {
|
||||
generateBtn.disabled = active;
|
||||
endlessBtn.disabled = active;
|
||||
stopBtn.classList.toggle('d-none', !active);
|
||||
if (!active) progressCont.classList.add('d-none');
|
||||
}
|
||||
|
||||
async function runOne(label) {
|
||||
if (document.getElementById('lucky-dip').checked) applyLuckyDip();
|
||||
|
||||
progressCont.classList.remove('d-none');
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.textContent = '0%';
|
||||
progressLbl.textContent = label;
|
||||
|
||||
const fd = new FormData(form);
|
||||
fd.append('client_id', clientId);
|
||||
|
||||
const resp = await fetch(form.action, {
|
||||
method: 'POST', body: fd,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
currentPromptId = data.prompt_id;
|
||||
progressLbl.textContent = 'Queued...';
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = 'Queued';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
await waitForCompletion(currentPromptId);
|
||||
await finalizeGeneration(document.getElementById('character').value, currentPromptId);
|
||||
currentPromptId = null;
|
||||
}
|
||||
|
||||
async function runLoop(endless) {
|
||||
const total = endless ? Infinity : (parseInt(numInput.value) || 1);
|
||||
stopRequested = false;
|
||||
setGeneratingState(true);
|
||||
let n = 0;
|
||||
try {
|
||||
while (!stopRequested && n < total) {
|
||||
n++;
|
||||
const lbl = endless ? `Generating #${n} (endless)...`
|
||||
: total === 1 ? 'Starting...'
|
||||
: `Generating ${n} / ${total}...`;
|
||||
await runOne(lbl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Generation failed: ' + err.message);
|
||||
} finally {
|
||||
setGeneratingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (e) => { e.preventDefault(); runLoop(false); });
|
||||
endlessBtn.addEventListener('click', () => runLoop(true));
|
||||
stopBtn.addEventListener('click', () => {
|
||||
stopRequested = true;
|
||||
progressLbl.textContent = 'Stopping after current image...';
|
||||
});
|
||||
|
||||
document.getElementById('random-char-btn').addEventListener('click', () => {
|
||||
const opts = Array.from(document.getElementById('character').options).filter(o => o.value);
|
||||
if (opts.length) {
|
||||
document.getElementById('character').value = opts[Math.floor(Math.random() * opts.length)].value;
|
||||
buildPromptPreview();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('random-ckpt-btn').addEventListener('click', () => {
|
||||
const opts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
||||
if (opts.length)
|
||||
document.getElementById('checkpoint').value = opts[Math.floor(Math.random() * opts.length)].value;
|
||||
});
|
||||
|
||||
document.getElementById('character').addEventListener('change', buildPromptPreview);
|
||||
|
||||
// Pre-populate from gallery URL params (?action=slug, ?outfit=slug, etc.)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const catMap = {
|
||||
action: { field: 'action_slugs', catKey: 'action' },
|
||||
outfit: { field: 'outfit_slugs', catKey: 'outfit' },
|
||||
scene: { field: 'scene_slugs', catKey: 'scene' },
|
||||
style: { field: 'style_slugs', catKey: 'style' },
|
||||
detailer: { field: 'detailer_slugs', catKey: 'detailer' },
|
||||
};
|
||||
let preselected = false;
|
||||
for (const [param, { field, catKey }] of Object.entries(catMap)) {
|
||||
const val = urlParams.get(param);
|
||||
if (!val) continue;
|
||||
const cb = document.querySelector(`input[name="${field}"][value="${CSS.escape(val)}"]`);
|
||||
if (cb) {
|
||||
cb.checked = true;
|
||||
updateMixBadge(catKey, field);
|
||||
// Expand the accordion panel
|
||||
const panel = document.getElementById(`mix-${catKey}`);
|
||||
if (panel) new bootstrap.Collapse(panel, { toggle: false }).show();
|
||||
preselected = true;
|
||||
}
|
||||
}
|
||||
if (preselected) buildPromptPreview();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,11 +15,27 @@
|
||||
<!-- Batch Progress Bar -->
|
||||
<div id="batch-progress-container" class="card mb-4 d-none">
|
||||
<div class="card-body">
|
||||
<h5 id="batch-status-text">Batch Generating...</h5>
|
||||
<div class="progress mt-2" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="batch-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width: 0%">0%</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Overall Batch Progress</small>
|
||||
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
|
||||
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small id="current-char-name" class="text-muted mb-1"></small>
|
||||
<small id="current-step-progress" class="text-muted mb-1"></small>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
|
||||
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="current-char-name" class="small text-muted mt-2 mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,21 +74,59 @@
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const progressBar = document.getElementById('batch-progress-bar');
|
||||
const taskProgressBar = document.getElementById('task-progress-bar');
|
||||
const container = document.getElementById('batch-progress-container');
|
||||
const statusText = document.getElementById('batch-status-text');
|
||||
const nodeStatus = document.getElementById('batch-node-status');
|
||||
const charNameText = document.getElementById('current-char-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
const clientId = 'gallery_batch_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character 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 === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
|
||||
if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / 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 = "";
|
||||
// Reset task bar for new node if it's not sampling
|
||||
if (nodeId !== "3") {
|
||||
taskProgressBar.style.width = '100%';
|
||||
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
|
||||
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -112,12 +166,16 @@
|
||||
|
||||
let completed = 0;
|
||||
for (const char of missing) {
|
||||
completed++;
|
||||
const percent = Math.round((completed / missing.length) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
statusText.textContent = `Batch Generating: ${completed} / ${missing.length}`;
|
||||
statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`;
|
||||
charNameText.textContent = `Current: ${char.name}`;
|
||||
nodeStatus.textContent = "Queuing...";
|
||||
|
||||
taskProgressBar.style.width = '100%';
|
||||
taskProgressBar.textContent = 'Queued';
|
||||
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/character/${char.slug}/generate`, {
|
||||
@@ -148,10 +206,17 @@
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${char.name}:`, err);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
statusText.textContent = "Batch Complete!";
|
||||
charNameText.textContent = "";
|
||||
nodeStatus.textContent = "Done";
|
||||
stepProgressText.textContent = "";
|
||||
taskProgressBar.style.width = '0%';
|
||||
taskProgressBar.textContent = '';
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
setTimeout(() => { container.classList.add('d-none'); }, 5000);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -24,8 +25,10 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,23 +196,37 @@
|
||||
const clientId = 'outfit_detail_' + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// ComfyUI WebSocket
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character LoRA",
|
||||
"17": "Outfit LoRA",
|
||||
"18": "Action LoRA",
|
||||
"19": "Style/Detailer LoRA",
|
||||
"8": "Decoding Image",
|
||||
"9": "Saving Image"
|
||||
};
|
||||
|
||||
let currentPromptId = null;
|
||||
let currentAction = null;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (!currentPromptId) return;
|
||||
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
if (!currentPromptId) {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / max) * 100);
|
||||
@@ -220,10 +234,16 @@
|
||||
progressBar.textContent = `${percent}%`;
|
||||
}
|
||||
else if (msg.type === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
// Execution finished via WebSocket
|
||||
console.log('Finished via WebSocket');
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
const nodeName = nodeNames[nodeId] || `Processing...`;
|
||||
progressLabel.textContent = nodeName;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -290,6 +310,9 @@
|
||||
|
||||
currentPromptId = data.prompt_id;
|
||||
progressLabel.textContent = 'Queued...';
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = 'Queued';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
// Wait for completion (WebSocket or Polling)
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
@@ -16,11 +16,27 @@
|
||||
<!-- Batch Progress Bar -->
|
||||
<div id="batch-progress-container" class="card mb-4 d-none">
|
||||
<div class="card-body">
|
||||
<h5 id="batch-status-text">Batch Generating Outfits...</h5>
|
||||
<div class="progress mt-2" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="batch-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width: 0%">0%</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h5 id="batch-status-text" class="mb-0">Batch Generating Outfits...</h5>
|
||||
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Overall Batch Progress</small>
|
||||
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
|
||||
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small id="current-item-name" class="text-muted mb-1"></small>
|
||||
<small id="current-step-progress" class="text-muted mb-1"></small>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
|
||||
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="current-item-name" class="small text-muted mt-2 mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,21 +75,58 @@
|
||||
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 = 'outfits_batch_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character 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 === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
|
||||
if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -113,15 +166,18 @@
|
||||
|
||||
let completed = 0;
|
||||
for (const item of missing) {
|
||||
completed++;
|
||||
const percent = Math.round((completed / missing.length) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
statusText.textContent = `Batch Generating Outfits: ${completed} / ${missing.length}`;
|
||||
statusText.textContent = `Batch Generating Outfits: ${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 {
|
||||
// Random character for outfit preview
|
||||
const genResp = await fetch(`/outfit/${item.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
@@ -154,10 +210,17 @@
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
statusText.textContent = "Batch Outfit Generation Complete!";
|
||||
itemNameText.textContent = "";
|
||||
nodeStatus.textContent = "Done";
|
||||
stepProgressText.textContent = "";
|
||||
taskProgressBar.style.width = '0%';
|
||||
taskProgressBar.textContent = '';
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
setTimeout(() => { container.classList.add('d-none'); }, 5000);
|
||||
|
||||
@@ -212,23 +212,58 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Generate a unique client ID
|
||||
const clientId = 'scene_detail_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
|
||||
// ComfyUI WebSocket
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character 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) => {
|
||||
if (!currentPromptId) return;
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'progress') {
|
||||
const percent = Math.round((msg.data.value / msg.data.max) * 100);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
if (!currentPromptId) {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / max) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
}
|
||||
else if (msg.type === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
// Execution finished via WebSocket
|
||||
console.log('Finished via WebSocket');
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
const nodeName = nodeNames[nodeId] || `Processing...`;
|
||||
progressLabel.textContent = nodeName;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -240,12 +275,17 @@
|
||||
resolve();
|
||||
};
|
||||
resolveCompletion = checkResolve;
|
||||
|
||||
// Fallback polling in case WebSocket is blocked (403)
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/check_status/${promptId}`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'finished') checkResolve();
|
||||
} catch (err) {}
|
||||
if (data.status === 'finished') {
|
||||
console.log('Finished via Polling');
|
||||
checkResolve();
|
||||
}
|
||||
} catch (err) { console.error('Polling error:', err); }
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
@@ -253,6 +293,7 @@
|
||||
form.addEventListener('submit', async (e) => {
|
||||
const submitter = e.submitter;
|
||||
if (!submitter || submitter.value !== 'preview') return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
@@ -262,7 +303,7 @@
|
||||
progressContainer.classList.remove('d-none');
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.textContent = '0%';
|
||||
progressLabel.textContent = 'Queued...';
|
||||
progressLabel.textContent = 'Starting...';
|
||||
|
||||
try {
|
||||
const response = await fetch(form.getAttribute('action'), {
|
||||
@@ -270,12 +311,30 @@
|
||||
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');
|
||||
|
||||
// Wait for completion (WebSocket or Polling)
|
||||
await waitForCompletion(currentPromptId);
|
||||
|
||||
// Finalize
|
||||
finalizeGeneration(currentPromptId);
|
||||
currentPromptId = null;
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Request failed');
|
||||
progressContainer.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
@@ -289,19 +348,33 @@
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST', body: formData });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update preview image
|
||||
previewImg.src = data.image_url;
|
||||
if (previewCard) previewCard.classList.remove('d-none');
|
||||
document.getElementById('replace-cover-btn').disabled = false;
|
||||
|
||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||
if (replaceBtn) {
|
||||
replaceBtn.disabled = false;
|
||||
const form = replaceBtn.closest('form');
|
||||
if (form) {
|
||||
form.action = `/scene/{{ scene.slug }}/replace_cover_from_preview`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert('Save failed: ' + data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Finalize request failed');
|
||||
} finally {
|
||||
progressContainer.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Image modal function
|
||||
function showImage(src) {
|
||||
document.getElementById('modalImage').src = src;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
<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>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="me-2">
|
||||
<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>
|
||||
</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>
|
||||
@@ -18,11 +23,27 @@
|
||||
<!-- Batch Progress Bar -->
|
||||
<div id="batch-progress-container" class="card mb-4 d-none">
|
||||
<div class="card-body">
|
||||
<h5 id="batch-status-text">Batch Generating Scenes...</h5>
|
||||
<div class="progress mt-2" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="batch-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width: 0%">0%</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h5 id="batch-status-text" class="mb-0">Batch Generating Scenes...</h5>
|
||||
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Overall Batch Progress</small>
|
||||
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
|
||||
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small id="current-scene-name" class="text-muted mb-1"></small>
|
||||
<small id="current-step-progress" class="text-muted mb-1"></small>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
|
||||
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="current-scene-name" class="small text-muted mt-2 mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,22 +82,60 @@
|
||||
<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 sceneNameText = document.getElementById('current-scene-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
const clientId = 'scenes_batch_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character 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 === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
|
||||
if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,16 +170,21 @@
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
container.classList.remove('d-none');
|
||||
|
||||
let completed = 0;
|
||||
for (const scene of missing) {
|
||||
completed++;
|
||||
const percent = Math.round((completed / missing.length) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
statusText.textContent = `Batch Generating Scenes: ${completed} / ${missing.length}`;
|
||||
statusText.textContent = `Batch Generating Scenes: ${completed + 1} / ${missing.length}`;
|
||||
sceneNameText.textContent = `Current: ${scene.name}`;
|
||||
nodeStatus.textContent = "Queuing...";
|
||||
|
||||
taskProgressBar.style.width = '100%';
|
||||
taskProgressBar.textContent = 'Queued';
|
||||
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/scene/${scene.slug}/generate`, {
|
||||
@@ -155,10 +219,17 @@
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${scene.name}:`, err);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
statusText.textContent = "Batch Scene Generation Complete!";
|
||||
sceneNameText.textContent = "";
|
||||
nodeStatus.textContent = "Done";
|
||||
stepProgressText.textContent = "";
|
||||
taskProgressBar.style.width = '0%';
|
||||
taskProgressBar.textContent = '';
|
||||
batchBtn.disabled = false;
|
||||
setTimeout(() => { container.classList.add('d-none'); }, 5000);
|
||||
}
|
||||
@@ -173,6 +244,17 @@
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} scenes?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm("This will unassign ALL current scene cover images and generate new ones. Proceed?")) return;
|
||||
|
||||
const clearResp = await fetch('/clear_all_scene_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 %}
|
||||
|
||||
@@ -4,31 +4,77 @@
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white">Application Settings</div>
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Application Settings</h5>
|
||||
<span class="badge bg-primary">LLM Configuration</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<h5 class="card-title mb-3">LLM Configuration (OpenRouter)</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="api_key" class="form-label">OpenRouter API Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="api_key" name="api_key" value="{{ settings.openrouter_api_key or '' }}">
|
||||
<button class="btn btn-outline-secondary" type="button" id="connect-btn">Connect & Load Models</button>
|
||||
</div>
|
||||
<div class="form-text">Required for AI text generation features.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="model" class="form-label">Model Selection</label>
|
||||
<select class="form-select" id="model" name="model">
|
||||
<option value="{{ settings.openrouter_model }}" selected>{{ settings.openrouter_model }}</option>
|
||||
<div class="mb-4">
|
||||
<label for="llm_provider" class="form-label fw-bold">LLM Provider</label>
|
||||
<select class="form-select form-select-lg" id="llm_provider" name="llm_provider">
|
||||
<option value="openrouter" {% if settings.llm_provider == 'openrouter' %}selected{% endif %}>OpenRouter (Cloud)</option>
|
||||
<option value="ollama" {% if settings.llm_provider == 'ollama' %}selected{% endif %}>Ollama (Local)</option>
|
||||
<option value="lmstudio" {% if settings.llm_provider == 'lmstudio' %}selected{% endif %}>LMStudio (Local)</option>
|
||||
</select>
|
||||
<div class="form-text">Click "Connect" above to load the latest available models.</div>
|
||||
<div class="form-text">Choose where your AI text generation requests are processed.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
<hr>
|
||||
|
||||
<!-- OpenRouter Settings -->
|
||||
<div id="openrouter-settings" class="provider-settings" {% if settings.llm_provider != 'openrouter' %}style="display:none;"{% endif %}>
|
||||
<h5 class="mb-3 text-primary">OpenRouter Configuration</h5>
|
||||
<div class="mb-3">
|
||||
<label for="api_key" class="form-label">API Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="api_key" name="api_key" value="{{ settings.openrouter_api_key or '' }}">
|
||||
<button class="btn btn-outline-primary" type="button" id="connect-openrouter-btn">Load Models</button>
|
||||
</div>
|
||||
<div class="form-text">Get your key at <a href="https://openrouter.ai/" target="_blank">openrouter.ai</a></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="model" class="form-label">Model Selection</label>
|
||||
<select class="form-select" id="model" name="model">
|
||||
<option value="{{ settings.openrouter_model }}" selected>{{ settings.openrouter_model }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local LLM Settings (Ollama/LMStudio) -->
|
||||
<div id="local-settings" class="provider-settings" {% if settings.llm_provider == 'openrouter' %}style="display:none;"{% endif %}>
|
||||
<h5 class="mb-3 text-primary">Local LLM Configuration</h5>
|
||||
<div class="mb-3">
|
||||
<label for="local_base_url" class="form-label">Base URL</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="local_base_url" name="local_base_url"
|
||||
placeholder="e.g. http://localhost:11434/v1"
|
||||
value="{{ settings.local_base_url or '' }}">
|
||||
<button class="btn btn-outline-primary" type="button" id="connect-local-btn">Load Models</button>
|
||||
</div>
|
||||
<div id="url-help" class="form-text">
|
||||
Ollama default: <code>http://localhost:11434/v1</code><br>
|
||||
LMStudio default: <code>http://localhost:1234/v1</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="local_model" class="form-label">Model Selection</label>
|
||||
<select class="form-select" id="local_model" name="local_model">
|
||||
{% if settings.local_model %}
|
||||
<option value="{{ settings.local_model }}" selected>{{ settings.local_model }}</option>
|
||||
{% else %}
|
||||
<option value="" selected disabled>Select a model...</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">Ensure your local LLM server is running and API is enabled.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Save All Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -41,54 +87,108 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
const providerSelect = document.getElementById('llm_provider');
|
||||
const openrouterSettings = document.getElementById('openrouter-settings');
|
||||
const localSettings = document.getElementById('local-settings');
|
||||
const localBaseUrlInput = document.getElementById('local_base_url');
|
||||
|
||||
// Toggle visibility based on provider
|
||||
providerSelect.addEventListener('change', () => {
|
||||
if (providerSelect.value === 'openrouter') {
|
||||
openrouterSettings.style.display = 'block';
|
||||
localSettings.style.display = 'none';
|
||||
} else {
|
||||
openrouterSettings.style.display = 'none';
|
||||
localSettings.style.display = 'block';
|
||||
|
||||
// Auto-fill default URLs if empty
|
||||
if (!localBaseUrlInput.value) {
|
||||
if (providerSelect.value === 'ollama') {
|
||||
localBaseUrlInput.value = 'http://localhost:11434/v1';
|
||||
} else if (providerSelect.value === 'lmstudio') {
|
||||
localBaseUrlInput.value = 'http://localhost:1234/v1';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// OpenRouter Model Loading
|
||||
const connectOpenRouterBtn = document.getElementById('connect-openrouter-btn');
|
||||
const apiKeyInput = document.getElementById('api_key');
|
||||
const modelSelect = document.getElementById('model');
|
||||
const currentModel = "{{ settings.openrouter_model }}";
|
||||
|
||||
connectBtn.addEventListener('click', async () => {
|
||||
connectOpenRouterBtn.addEventListener('click', async () => {
|
||||
const apiKey = apiKeyInput.value;
|
||||
if (!apiKey) {
|
||||
alert('Please enter an API Key first.');
|
||||
return;
|
||||
}
|
||||
if (!apiKey) { alert('Please enter an API Key first.'); return; }
|
||||
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = 'Connecting...';
|
||||
connectOpenRouterBtn.disabled = true;
|
||||
connectOpenRouterBtn.textContent = 'Loading...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('api_key', apiKey);
|
||||
|
||||
const response = await fetch('/get_openrouter_models', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const response = await fetch('/get_openrouter_models', { method: 'POST', body: formData });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Error: ' + data.error);
|
||||
} else {
|
||||
// Clear and populate dropdown
|
||||
modelSelect.innerHTML = '';
|
||||
data.models.sort((a, b) => a.name.localeCompare(b.name)).forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id;
|
||||
option.textContent = model.name;
|
||||
if (model.id === currentModel) {
|
||||
option.selected = true;
|
||||
}
|
||||
if (model.id === currentModel) option.selected = true;
|
||||
modelSelect.appendChild(option);
|
||||
});
|
||||
alert('Model list loaded successfully!');
|
||||
alert('OpenRouter models loaded successfully!');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to connect to OpenRouter.');
|
||||
} finally {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Connect & Load Models';
|
||||
connectOpenRouterBtn.disabled = false;
|
||||
connectOpenRouterBtn.textContent = 'Load Models';
|
||||
}
|
||||
});
|
||||
|
||||
// Local Model Loading
|
||||
const connectLocalBtn = document.getElementById('connect-local-btn');
|
||||
const localModelSelect = document.getElementById('local_model');
|
||||
const currentLocalModel = "{{ settings.local_model }}";
|
||||
|
||||
connectLocalBtn.addEventListener('click', async () => {
|
||||
const baseUrl = localBaseUrlInput.value;
|
||||
if (!baseUrl) { alert('Please enter a Base URL first.'); return; }
|
||||
|
||||
connectLocalBtn.disabled = true;
|
||||
connectLocalBtn.textContent = 'Loading...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('base_url', baseUrl);
|
||||
const response = await fetch('/get_local_models', { method: 'POST', body: formData });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Error: ' + data.error);
|
||||
} else {
|
||||
localModelSelect.innerHTML = '';
|
||||
data.models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id;
|
||||
option.textContent = model.name;
|
||||
if (model.id === currentLocalModel) option.selected = true;
|
||||
localModelSelect.appendChild(option);
|
||||
});
|
||||
if (data.models.length === 0) alert('No models found at this URL.');
|
||||
else alert('Local models loaded successfully!');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to connect to local LLM server. Make sure it is running and CORS is enabled if needed.');
|
||||
} finally {
|
||||
connectLocalBtn.disabled = false;
|
||||
connectLocalBtn.textContent = 'Load Models';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,23 +209,37 @@
|
||||
const clientId = 'style_detail_' + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// ComfyUI WebSocket
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character LoRA",
|
||||
"17": "Outfit LoRA",
|
||||
"18": "Action LoRA",
|
||||
"19": "Style/Detailer LoRA",
|
||||
"8": "Decoding Image",
|
||||
"9": "Saving Image"
|
||||
};
|
||||
|
||||
let currentPromptId = null;
|
||||
let currentAction = null;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (!currentPromptId) return;
|
||||
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
if (!currentPromptId) {
|
||||
const queueRemaining = msg.data.status.exec_info.queue_remaining;
|
||||
if (queueRemaining > 0) {
|
||||
progressLabel.textContent = `Queue position: ${queueRemaining}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / max) * 100);
|
||||
@@ -233,9 +247,16 @@
|
||||
progressBar.textContent = `${percent}%`;
|
||||
}
|
||||
else if (msg.type === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
// Execution finished
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
|
||||
const nodeId = msg.data.node;
|
||||
if (nodeId === null) {
|
||||
// Execution finished via WebSocket
|
||||
console.log('Finished via WebSocket');
|
||||
if (resolveCompletion) resolveCompletion();
|
||||
} else {
|
||||
const nodeName = nodeNames[nodeId] || `Processing...`;
|
||||
progressLabel.textContent = nodeName;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -249,7 +270,7 @@
|
||||
};
|
||||
resolveCompletion = checkResolve;
|
||||
|
||||
// Fallback polling
|
||||
// Fallback polling in case WebSocket is blocked (403)
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/check_status/${promptId}`);
|
||||
@@ -294,6 +315,10 @@
|
||||
|
||||
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, currentAction);
|
||||
currentPromptId = null;
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
<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>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="me-2">
|
||||
<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>
|
||||
</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>
|
||||
@@ -19,11 +23,27 @@
|
||||
<!-- Batch Progress Bar -->
|
||||
<div id="batch-progress-container" class="card mb-4 d-none">
|
||||
<div class="card-body">
|
||||
<h5 id="batch-status-text">Batch Generating Styles...</h5>
|
||||
<div class="progress mt-2" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="batch-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width: 0%">0%</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h5 id="batch-status-text" class="mb-0">Batch Generating Styles...</h5>
|
||||
<span id="batch-node-status" class="badge bg-info text-dark">Starting...</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Overall Batch Progress</small>
|
||||
<div class="progress" role="progressbar" aria-label="Batch Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 10px;">
|
||||
<div id="batch-progress-bar" class="progress-bar bg-success" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small id="current-style-name" class="text-muted mb-1"></small>
|
||||
<small id="current-step-progress" class="text-muted mb-1"></small>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Task Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 20px;">
|
||||
<div id="task-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="current-style-name" class="small text-muted mt-2 mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,21 +84,58 @@
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const progressBar = document.getElementById('batch-progress-bar');
|
||||
const taskProgressBar = document.getElementById('task-progress-bar');
|
||||
const container = document.getElementById('batch-progress-container');
|
||||
const statusText = document.getElementById('batch-status-text');
|
||||
const nodeStatus = document.getElementById('batch-node-status');
|
||||
const styleNameText = document.getElementById('current-style-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
const clientId = 'styles_batch_' + Math.random().toString(36).substring(2, 15);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
|
||||
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
|
||||
|
||||
const nodeNames = {
|
||||
"3": "Sampling",
|
||||
"11": "Face Detailing",
|
||||
"13": "Hand Detailing",
|
||||
"4": "Loading Models",
|
||||
"16": "Character 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 === 'executing') {
|
||||
if (msg.data.node === null && msg.data.prompt_id === currentPromptId) {
|
||||
|
||||
if (msg.type === 'progress') {
|
||||
if (msg.data.prompt_id !== currentPromptId) return;
|
||||
const value = msg.data.value;
|
||||
const max = msg.data.max;
|
||||
const percent = Math.round((value / 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -118,12 +175,16 @@
|
||||
|
||||
let completed = 0;
|
||||
for (const style of missing) {
|
||||
completed++;
|
||||
const percent = Math.round((completed / missing.length) * 100);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = `${percent}%`;
|
||||
statusText.textContent = `Batch Generating Styles: ${completed} / ${missing.length}`;
|
||||
statusText.textContent = `Batch Generating Styles: ${completed + 1} / ${missing.length}`;
|
||||
styleNameText.textContent = `Current: ${style.name}`;
|
||||
nodeStatus.textContent = "Queuing...";
|
||||
|
||||
taskProgressBar.style.width = '100%';
|
||||
taskProgressBar.textContent = 'Queued';
|
||||
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/style/${style.slug}/generate`, {
|
||||
@@ -158,10 +219,17 @@
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${style.name}:`, err);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
statusText.textContent = "Batch Style Generation Complete!";
|
||||
styleNameText.textContent = "";
|
||||
nodeStatus.textContent = "Done";
|
||||
stepProgressText.textContent = "";
|
||||
taskProgressBar.style.width = '0%';
|
||||
taskProgressBar.textContent = '';
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
setTimeout(() => { container.classList.add('d-none'); }, 5000);
|
||||
|
||||
Reference in New Issue
Block a user