5 Commits

Author SHA1 Message Date
Aodhan Collins
ee36caccd6 Sort all batch generation queues by JSON filename
All get_missing_* routes and generate_missing routes now order results
by filename (alphabetical) instead of display name or undefined order.
Checkpoint uses checkpoint_path as the equivalent sort key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:08:37 +00:00
Aodhan Collins
e226548471 Merge branch 'logging'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:02:41 +00:00
Aodhan Collins
b9196ef5f5 Add structured logging for job queue and workflow prompts
Replace bare print() calls with Python logging module. Job lifecycle
(enqueue, start, ComfyUI acceptance, completion, failure) now emits
timestamped INFO logs to stdout, captured by Docker. Failures use
logger.exception() for full tracebacks. Workflow prompt block logs as
a single INFO entry; LoRA chain details moved to DEBUG level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:55:53 +00:00
Aodhan Collins
a38915b354 Refactor UI, settings, and code quality across all categories
- Fix Replace Cover: routes now read preview_path from form POST instead of session (session writes from background threads were lost)
- Fix batch generation: submit all jobs immediately, poll all in parallel via Promise.all
- Fix label NameError in character generate route
- Fix style detail missing characters context
- Selected Preview pane: click any image to select it; data-preview-path on all images across all 8 detail templates
- Gallery → Library rename across all index page headings and navbar
- Settings: add configurable LoRA/checkpoint directories; default checkpoint selector moved from navbar to settings page
- Consolidate 6 get_available_*_loras() into single get_available_loras(category) reading from Settings
- ComfyUI tooltip shows currently loaded checkpoint name
- Remove navbar checkpoint bar
- Phase 4 cleanup: remove dead _queue_generation(), add session.modified, standardize log prefixes, rename action_type → action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:48:28 +00:00
Aodhan Collins
da55b0889b Add Docker support and refactor prompt/queue internals
- Add Dockerfile, docker-compose.yml, .dockerignore for containerised deployment
- Extract _resolve_character(), _ensure_character_fields(), _append_background() helpers to eliminate repeated inline character-field injection and background-tag patterns across all secondary-category generate routes
- Add _IDENTITY_KEYS / _WARDROBE_KEYS constants
- Fix build_extras_prompt() bug: detailer prompt (a list) was being appended as a single item instead of extended
- Replace all per-route _finalize closures with _make_finalize() factory, reducing duplication across 10 generate routes
- Add _prune_job_history() called each worker loop iteration to prevent unbounded memory growth
- Remove 10 orphaned legacy finalize_generation HTTP routes and check_status route (superseded by job queue API since job-queue branch)
- Remove one-time migration scripts (migrate_actions, migrate_detailers, migrate_lora_weight_range, migrate_wardrobe)
- Update CLAUDE.md and README.md to document new helpers, queue architecture, and Docker deployment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 16:46:36 +00:00
30 changed files with 1140 additions and 2127 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
instance/
flask_session/
static/uploads/
tools/
.git/

View File

@@ -96,6 +96,30 @@ Cross-deduplicates tags between the positive and negative prompt strings. For ea
Called as the last step of `_prepare_workflow`, after `_apply_checkpoint_settings` has added `base_positive`/`base_negative`, so it operates on fully-assembled prompts.
### `_IDENTITY_KEYS` / `_WARDROBE_KEYS` (module-level constants)
Lists of canonical field names for the `identity` and `wardrobe` sections. Used by `_ensure_character_fields()` to avoid hard-coding key lists in every route.
### `_resolve_character(character_slug)`
Returns a `Character` ORM object for a given slug string. Handles the `"__random__"` sentinel by selecting a random character. Returns `None` if `character_slug` is falsy or no match is found. Every route that accepts an optional character dropdown (outfit, action, style, scene, detailer, checkpoint, look generate routes) uses this instead of an inline if/elif block.
### `_ensure_character_fields(character, selected_fields, include_wardrobe=True, include_defaults=False)`
Mutates `selected_fields` in place, appending any populated identity, wardrobe, and optional defaults keys that are not already present. Ensures `"special::name"` is always included. Called in every secondary-category generate route immediately after `_resolve_character()` to guarantee the character's essential fields are sent to `build_prompt`.
### `_append_background(prompts, character=None)`
Appends a `"<primary_color> simple background"` tag (or plain `"simple background"` if no primary color) to `prompts['main']`. Called in outfit, action, style, detailer, and checkpoint generate routes instead of repeating the same inline string construction.
### `_make_finalize(category, slug, db_model_class=None, action=None)`
Factory function that returns a `_finalize(comfy_prompt_id, job)` callback closure. The closure:
1. Calls `get_history()` and `get_image()` to retrieve the generated image from ComfyUI.
2. Saves the image to `static/uploads/<category>/<slug>/gen_<timestamp>.png`.
3. Sets `job['result']` with `image_url` and `relative_path`.
4. If `db_model_class` is provided **and** (`action` is `None` or `action == 'replace'`), updates the ORM object's `image_path` and commits.
All `generate` routes pass a `_make_finalize(...)` call as the finalize argument to `_enqueue_job()` instead of defining an inline closure.
### `_prune_job_history(max_age_seconds=3600)`
Removes entries from `_job_history` that are in a terminal state (`done`, `failed`, `removed`) and older than `max_age_seconds`. Called at the end of every worker loop iteration to prevent unbounded memory growth.
### `_queue_generation(character, action, selected_fields, client_id)`
Convenience wrapper for character detail page generation. Loads workflow, calls `build_prompt`, calls `_prepare_workflow`, calls `queue_prompt`.
@@ -225,8 +249,7 @@ Checkpoint JSONs are keyed by `checkpoint_path`. If no JSON exists for a discove
### Characters
- `GET /` — character gallery (index)
- `GET /character/<slug>` — character detail with generation UI
- `POST /character/<slug>/generate` — queue generation (AJAX or form)
- `POST /character/<slug>/finalize_generation/<prompt_id>` — retrieve image from ComfyUI
- `POST /character/<slug>/generate` — queue generation (AJAX or form); returns `{"job_id": ...}`
- `POST /character/<slug>/replace_cover_from_preview` — promote preview to cover
- `GET/POST /character/<slug>/edit` — edit character data
- `POST /character/<slug>/upload` — upload cover image
@@ -239,8 +262,7 @@ Checkpoint JSONs are keyed by `checkpoint_path`. If no JSON exists for a discove
Each category follows the same URL pattern:
- `GET /<category>/` — gallery
- `GET /<category>/<slug>` — detail + generation UI
- `POST /<category>/<slug>/generate` — queue generation
- `POST /<category>/<slug>/finalize_generation/<prompt_id>` — retrieve image
- `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}`
- `POST /<category>/<slug>/replace_cover_from_preview`
- `GET/POST /<category>/<slug>/edit`
- `POST /<category>/<slug>/upload`
@@ -254,15 +276,13 @@ Each category follows the same URL pattern:
- `GET /looks` — gallery
- `GET /look/<slug>` — detail
- `GET/POST /look/<slug>/edit`
- `POST /look/<slug>/generate`
- `POST /look/<slug>/finalize_generation/<prompt_id>`
- `POST /look/<slug>/generate` — queue generation; returns `{"job_id": ...}`
- `POST /look/<slug>/replace_cover_from_preview`
- `GET/POST /look/create`
- `POST /looks/rescan`
### Generator (Mix & Match)
- `GET/POST /generator` — freeform generator with multi-select accordion UI
- `POST /generator/finalize/<slug>/<prompt_id>` — retrieve image
- `POST /generator/preview_prompt` — AJAX: preview composed prompt without generating
### Checkpoints
@@ -271,11 +291,16 @@ Each category follows the same URL pattern:
- `POST /checkpoint/<slug>/save_json`
- `POST /checkpoints/rescan`
### Job Queue API
All generation routes use the background job queue. Frontend polls:
- `GET /api/queue/<job_id>/status` — returns `{"status": "pending"|"running"|"done"|"failed", "result": {...}}`
Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes.
### Utilities
- `POST /set_default_checkpoint` — save default checkpoint to session
- `GET /check_status/<prompt_id>` — poll ComfyUI for completion
- `GET /get_missing_{characters,outfits,actions,scenes}` — AJAX: list items without cover images
- `POST /generate_missing` — batch generate covers for characters
- `POST /generate_missing` — batch generate covers for all characters missing one (uses job queue)
- `POST /clear_all_covers` / `clear_all_{outfit,action,scene}_covers`
- `GET /gallery` — global image gallery browsing `static/uploads/`
- `GET/POST /settings` — LLM provider configuration
@@ -295,7 +320,7 @@ Each category follows the same URL pattern:
- `initJsonEditor(saveUrl)` — shared JSON editor modal (simple form + raw textarea tabs)
- Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template.
- **No `{% block head %}` exists** in layout.html — do not try to use it.
- Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `prompt_id`, opens a WebSocket to ComfyUI to show progress, then calls the finalize endpoint to save the image.
- Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `{"job_id": ...}` response, then polls `/api/queue/<job_id>/status` every ~1.5 seconds until `status == "done"`. The server-side worker handles all ComfyUI polling and image saving via the `_make_finalize()` callback. There are no client-facing finalize HTTP routes.
---
@@ -374,6 +399,8 @@ The Flask filesystem session stores:
## Running the App
### Directly (development)
```bash
cd /mnt/alexander/Projects/character-browser
source venv/bin/activate
@@ -384,6 +411,25 @@ The app runs in debug mode on port 5000 by default. ComfyUI must be running at `
The DB is initialised and all sync functions are called inside `with app.app_context():` at the bottom of `app.py` before `app.run()`.
### Docker
```bash
docker compose up -d
```
The compose file (`docker-compose.yml`) runs two services:
- **`danbooru-mcp`** — built from `https://git.liveaodh.com/aodhan/danbooru-mcp.git`; the MCP tag-search container used by `call_llm()`.
- **`app`** — the Flask app, exposed on host port **5782** → container port 5000.
Key environment variables set by compose:
- `COMFYUI_URL=http://10.0.0.200:8188` — points at ComfyUI on the Docker host network.
- `SKIP_MCP_AUTOSTART=true` — disables the app's built-in danbooru-mcp launch logic (compose manages it).
Volumes mounted into the app container:
- `./data`, `./static/uploads`, `./instance`, `./flask_session` — persistent app data.
- `/Volumes/ImageModels:/ImageModels:ro` — model files for checkpoint/LoRA scanning (**requires Docker Desktop file sharing enabled for `/Volumes/ImageModels`**).
- `/var/run/docker.sock` — Docker socket so the app can exec danbooru-mcp tool containers.
---
## Common Pitfalls
@@ -396,3 +442,4 @@ The DB is initialised and all sync functions are called inside `with app.app_con
- **AJAX detection**: `request.headers.get('X-Requested-With') == 'XMLHttpRequest'` determines whether to return JSON or redirect.
- **Session must be marked modified for JSON responses**: After setting session values in AJAX-responding routes, set `session.modified = True`.
- **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. When merging into `tags` for `build_prompt`, use `extend` for lists and `append` for strings — never append the list object itself or `", ".join()` will fail on the nested list item.
- **`_make_finalize` action semantics**: Pass `action=None` when the route should always update the DB cover (e.g. batch generate, checkpoint generate). Pass `action=request.form.get('action')` for routes that support both "preview" (no DB update) and "replace" (update DB). The factory skips the DB write when `action` is truthy and not `"replace"`.

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM python:3.12-slim
# Install system deps: git (for danbooru-mcp repo clone) + docker CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
ca-certificates \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Writable dirs that will typically be bind-mounted at runtime
RUN mkdir -p static/uploads instance flask_session data/characters data/clothing \
data/actions data/styles data/scenes data/detailers data/checkpoints data/looks
EXPOSE 5000
CMD ["python", "app.py"]

View File

@@ -39,6 +39,20 @@ A local web-based GUI for managing character profiles (JSON) and generating cons
## Setup & Installation
### Option A — Docker (recommended)
1. **Clone the repository.**
2. Edit `docker-compose.yml` if needed:
- Set `COMFYUI_URL` to your ComfyUI host/port.
- Adjust the `/Volumes/ImageModels` volume path to your model directory. If you're on Docker Desktop, add the path under **Settings → Resources → File Sharing** first.
3. **Start services:**
```bash
docker compose up -d
```
The app will be available at `http://localhost:5782`.
### Option B — Local (development)
1. **Clone the repository** to your local machine.
2. **Configure Paths**: Open `app.py` and update the following variables to match your system:
```python

1558
app.py

File diff suppressed because it is too large Load Diff

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
danbooru-mcp:
build: https://git.liveaodh.com/aodhan/danbooru-mcp.git
image: danbooru-mcp:latest
stdin_open: true
restart: unless-stopped
app:
build: .
ports:
- "5782:5000"
environment:
# ComfyUI runs on the Docker host
COMFYUI_URL: http://10.0.0.200:8188 # Compose manages danbooru-mcp — skip the app's auto-start logic
SKIP_MCP_AUTOSTART: "true"
volumes:
# Persistent data
- ./data:/app/data
- ./static/uploads:/app/static/uploads
- ./instance:/app/instance
- ./flask_session:/app/flask_session
# Model files (read-only — used for checkpoint/LoRA scanning)
- /Volumes/ImageModels:/ImageModels:ro
# Docker socket so the app can run danbooru-mcp tool containers
- /var/run/docker.sock:/var/run/docker.sock
extra_hosts:
# Resolve host.docker.internal on Linux hosts
- "host.docker.internal:host-gateway"
depends_on:
- danbooru-mcp
restart: unless-stopped

View File

@@ -1,63 +0,0 @@
import os
import json
from collections import OrderedDict
ACTIONS_DIR = 'data/actions'
def migrate_actions():
if not os.path.exists(ACTIONS_DIR):
print(f"Directory {ACTIONS_DIR} not found.")
return
count = 0
for filename in os.listdir(ACTIONS_DIR):
if not filename.endswith('.json'):
continue
filepath = os.path.join(ACTIONS_DIR, filename)
try:
with open(filepath, 'r') as f:
data = json.load(f, object_pairs_hook=OrderedDict)
# Check if participants already exists
if 'participants' in data:
print(f"Skipping {filename}: 'participants' already exists.")
continue
# Create new ordered dict to enforce order
new_data = OrderedDict()
# Copy keys up to 'action'
found_action = False
for key, value in data.items():
new_data[key] = value
if key == 'action':
found_action = True
# Insert participants here
new_data['participants'] = {
"solo_focus": "true",
"orientation": "MF"
}
# If 'action' wasn't found, append at the end
if not found_action:
print(f"Warning: 'action' key not found in {filename}. Appending 'participants' at the end.")
new_data['participants'] = {
"solo_focus": "true",
"orientation": "MF"
}
# Write back to file
with open(filepath, 'w') as f:
json.dump(new_data, f, indent=2)
count += 1
# print(f"Updated {filename}") # Commented out to reduce noise if many files
except Exception as e:
print(f"Error processing {filename}: {e}")
print(f"Migration complete. Updated {count} files.")
if __name__ == "__main__":
migrate_actions()

View File

@@ -1,61 +0,0 @@
import os
import json
import re
DETAILERS_DIR = 'data/detailers'
def migrate_detailers():
if not os.path.exists(DETAILERS_DIR):
print(f"Directory {DETAILERS_DIR} does not exist.")
return
count = 0
for filename in os.listdir(DETAILERS_DIR):
if filename.endswith('.json'):
file_path = os.path.join(DETAILERS_DIR, filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
# Create a new ordered dictionary
new_data = {}
# Copy existing fields up to 'prompt'
if 'detailer_id' in data:
new_data['detailer_id'] = data['detailer_id']
if 'detailer_name' in data:
new_data['detailer_name'] = data['detailer_name']
# Handle 'prompt'
prompt_val = data.get('prompt', [])
if isinstance(prompt_val, str):
# Split by comma and strip whitespace
new_data['prompt'] = [p.strip() for p in prompt_val.split(',') if p.strip()]
else:
new_data['prompt'] = prompt_val
# Insert 'focus'
if 'focus' not in data:
new_data['focus'] = ""
else:
new_data['focus'] = data['focus']
# Copy remaining fields
for key, value in data.items():
if key not in ['detailer_id', 'detailer_name', 'prompt', 'focus']:
new_data[key] = value
# Write back to file
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
print(f"Migrated {filename}")
count += 1
except Exception as e:
print(f"Error processing {filename}: {e}")
print(f"Migration complete. Processed {count} files.")
if __name__ == '__main__':
migrate_detailers()

View File

@@ -1,79 +0,0 @@
#!/usr/bin/env python3
"""
Migration: add lora_weight_min / lora_weight_max to every entity JSON.
For each JSON file in the target directories we:
- Read the existing lora_weight value (default 1.0 if missing)
- Write lora_weight_min = lora_weight (if not already set)
- Write lora_weight_max = lora_weight (if not already set)
- Leave lora_weight in place (the resolver still uses it as a fallback)
Directories processed (skip data/checkpoints — no LoRA weight there):
data/characters/
data/clothing/
data/actions/
data/styles/
data/scenes/
data/detailers/
data/looks/
"""
import glob
import json
import os
import sys
DIRS = [
'data/characters',
'data/clothing',
'data/actions',
'data/styles',
'data/scenes',
'data/detailers',
'data/looks',
]
dry_run = '--dry-run' in sys.argv
updated = 0
skipped = 0
errors = 0
for directory in DIRS:
pattern = os.path.join(directory, '*.json')
for path in sorted(glob.glob(pattern)):
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
lora = data.get('lora')
if not isinstance(lora, dict):
skipped += 1
continue
weight = float(lora.get('lora_weight', 1.0))
changed = False
if 'lora_weight_min' not in lora:
lora['lora_weight_min'] = weight
changed = True
if 'lora_weight_max' not in lora:
lora['lora_weight_max'] = weight
changed = True
if changed:
if not dry_run:
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
print(f" [{'DRY' if dry_run else 'OK'}] {path} weight={weight}")
updated += 1
else:
skipped += 1
except Exception as e:
print(f" [ERR] {path}: {e}", file=sys.stderr)
errors += 1
print(f"\nDone. updated={updated} skipped={skipped} errors={errors}")
if dry_run:
print("(dry-run — no files were written)")

View File

@@ -1,153 +0,0 @@
#!/usr/bin/env python3
"""
Migration script to convert wardrobe structure from flat to nested format.
Before:
"wardrobe": {
"headwear": "...",
"top": "...",
...
}
After:
"wardrobe": {
"default": {
"headwear": "...",
"top": "...",
...
}
}
This enables multiple outfits per character.
"""
import os
import json
from pathlib import Path
def migrate_wardrobe(characters_dir: str = "characters", dry_run: bool = False):
"""
Migrate all character JSON files to the new wardrobe structure.
Args:
characters_dir: Path to the directory containing character JSON files
dry_run: If True, only print what would be changed without modifying files
"""
characters_path = Path(characters_dir)
if not characters_path.exists():
print(f"Error: Directory '{characters_dir}' does not exist")
return
json_files = list(characters_path.glob("*.json"))
if not json_files:
print(f"No JSON files found in '{characters_dir}'")
return
migrated_count = 0
skipped_count = 0
error_count = 0
for json_file in json_files:
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Check if character has a wardrobe
if 'wardrobe' not in data:
print(f" [SKIP] {json_file.name}: No wardrobe field")
skipped_count += 1
continue
wardrobe = data['wardrobe']
# Check if already migrated (wardrobe contains 'default' key with nested dict)
if 'default' in wardrobe and isinstance(wardrobe['default'], dict):
# Verify it's actually the new format (has wardrobe keys inside)
expected_keys = {'headwear', 'top', 'legwear', 'footwear', 'hands', 'accessories',
'inner_layer', 'outer_layer', 'lower_body', 'gloves'}
if any(key in wardrobe['default'] for key in expected_keys):
print(f" [SKIP] {json_file.name}: Already migrated")
skipped_count += 1
continue
# Check if wardrobe is a flat structure (not already nested)
# A flat wardrobe has string values, a nested one has dict values
if not isinstance(wardrobe, dict):
print(f" [ERROR] {json_file.name}: Wardrobe is not a dictionary")
error_count += 1
continue
# Check if any value is a dict (indicating partial migration or different structure)
has_nested_values = any(isinstance(v, dict) for v in wardrobe.values())
if has_nested_values:
print(f" [SKIP] {json_file.name}: Wardrobe has nested values, may already be migrated")
skipped_count += 1
continue
# Perform migration
new_wardrobe = {
"default": wardrobe
}
data['wardrobe'] = new_wardrobe
if dry_run:
print(f" [DRY-RUN] {json_file.name}: Would migrate wardrobe")
print(f" Old: {json.dumps(wardrobe, indent=2)[:100]}...")
print(f" New: {json.dumps(new_wardrobe, indent=2)[:100]}...")
else:
with open(json_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f" [MIGRATED] {json_file.name}")
migrated_count += 1
except json.JSONDecodeError as e:
print(f" [ERROR] {json_file.name}: Invalid JSON - {e}")
error_count += 1
except Exception as e:
print(f" [ERROR] {json_file.name}: {e}")
error_count += 1
print()
print("=" * 50)
print(f"Migration complete:")
print(f" - Migrated: {migrated_count}")
print(f" - Skipped: {skipped_count}")
print(f" - Errors: {error_count}")
if dry_run:
print()
print("This was a dry run. No files were modified.")
print("Run with --execute to apply changes.")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Migrate character wardrobe structure to support multiple outfits"
)
parser.add_argument(
"--execute",
action="store_true",
help="Actually modify files (default is dry-run)"
)
parser.add_argument(
"--dir",
default="characters",
help="Directory containing character JSON files (default: characters)"
)
args = parser.parse_args()
print("=" * 50)
print("Wardrobe Migration Script")
print("=" * 50)
print(f"Directory: {args.dir}")
print(f"Mode: {'EXECUTE' if args.execute else 'DRY-RUN'}")
print("=" * 50)
print()
migrate_wardrobe(characters_dir=args.dir, dry_run=not args.execute)

View File

@@ -132,6 +132,15 @@ class Settings(db.Model):
openrouter_model = db.Column(db.String(100), default='google/gemini-2.0-flash-001')
local_base_url = db.Column(db.String(255), nullable=True)
local_model = db.Column(db.String(100), nullable=True)
# LoRA directories (absolute paths on disk)
lora_dir_characters = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Looks')
lora_dir_outfits = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Clothing')
lora_dir_actions = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Poses')
lora_dir_styles = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Styles')
lora_dir_scenes = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Backgrounds')
lora_dir_detailers = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Detailers')
# Checkpoint scan directories (comma-separated list of absolute paths)
checkpoint_dirs = db.Column(db.String(1000), default='/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob')
def __repr__(self):
return '<Settings>'

View File

@@ -56,7 +56,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if action.image_path %}
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid" data-preview-path="{{ action.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -97,35 +97,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_action_cover_from_preview', slug=action.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>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">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_action_cover_from_preview', slug=action.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -163,7 +148,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -288,7 +273,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -314,20 +300,30 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -335,17 +331,9 @@
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') {
clearInterval(poll);
resolve(data);
} else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll);
reject(new Error(data.error || 'Job failed'));
} else if (data.status === 'processing') {
progressLabel.textContent = 'Generating…';
} else {
progressLabel.textContent = 'Queued…';
}
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -355,12 +343,10 @@
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
@@ -368,31 +354,21 @@
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; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -400,7 +376,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -411,8 +387,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -427,43 +404,37 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentJobId = null;
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -474,10 +445,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
});

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Action Gallery</h2>
<h2>Action Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -47,7 +47,8 @@
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">
<img src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}" class="img-fluid"
data-preview-path="{{ ckpt.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span>
@@ -89,39 +90,22 @@
</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>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
<small>Selected 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>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>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">
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -131,7 +115,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('checkpoints_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('checkpoints_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -238,6 +222,7 @@
<img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="{{ img }}"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
</div>
@@ -259,9 +244,25 @@
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
let currentJobId = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -294,15 +295,8 @@
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
}
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
@@ -320,7 +314,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -329,6 +323,7 @@
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
title="${charName}">
@@ -347,36 +342,36 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
// Phase 1: submit all jobs immediately
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
const resp = await fetch(form.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
// Phase 2: poll all in parallel
batchLabel.textContent = `0 / ${pending.length} complete`;
let done = 0;
const total = pending.length;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -387,7 +382,7 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping after current submissions...';
});
// JSON Editor

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Checkpoint Gallery</h2>
<h2>Checkpoint Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -17,7 +17,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if character.image_path %}
<img src="{{ url_for('static', filename='uploads/' + character.image_path) }}" alt="{{ character.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + character.image_path) }}" alt="{{ character.name }}" class="img-fluid" data-preview-path="{{ character.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -44,35 +44,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_cover_from_preview', slug=character.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">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_cover_from_preview', slug=character.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>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>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">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -103,7 +88,7 @@
<h1 class="mb-0">{{ character.name }}</h1>
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
</div>
<a href="/" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="/" class="btn btn-outline-secondary">Back to Library</a>
</div>
<!-- Outfit Switcher -->
@@ -227,9 +212,27 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
// Clicking any image with data-preview-path selects it into the preview pane
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -254,55 +257,35 @@
}
form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') {
return;
}
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
// UI Reset
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST',
body: formData,
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;
}
if (data.error) { alert('Error: ' + data.error); return; }
currentJobId = data.job_id;
progressLabel.textContent = 'Queued…';
// Wait for the background worker to finish
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
// Image is already saved — just display it
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
@@ -312,8 +295,7 @@
}
});
});
// Image modal function
function showImage(src) {
document.getElementById('modalImage').src = src;
}

View File

@@ -56,7 +56,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if detailer.image_path %}
<img src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}" class="img-fluid" data-preview-path="{{ detailer.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span>
@@ -121,35 +121,20 @@
</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>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
<small>Selected Preview</small>
<form action="{{ url_for('replace_detailer_cover_from_preview', slug=detailer.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>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">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" 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_detailer_cover_from_preview', slug=detailer.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">
@@ -162,7 +147,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -257,7 +242,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -283,21 +269,31 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
const actionSelect = document.getElementById('action_select');
// Toggle character context info
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -307,8 +303,7 @@
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -318,9 +313,8 @@
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
@@ -330,26 +324,21 @@
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; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -357,7 +346,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -368,8 +357,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -384,12 +374,13 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
@@ -398,26 +389,25 @@
fd.append('extra_negative', document.getElementById('extra_negative').value);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -428,10 +418,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
});

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Detailer Gallery</h2>
<h2>Detailer Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Gallery
<h4 class="mb-0">Image Gallery
<span class="text-muted fs-6 fw-normal ms-2">{{ total }} image{{ 's' if total != 1 else '' }}</span>
</h4>
</div>

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Character Gallery</h2>
<h2>Character Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -26,7 +26,7 @@
<div class="vr mx-1 d-none d-lg-block"></div>
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
<a href="/gallery" class="btn btn-sm btn-outline-light">Gallery</a>
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div>
<!-- Queue indicator -->
@@ -48,19 +48,6 @@
</div>
</nav>
<div class="default-checkpoint-bar border-bottom mb-4">
<div class="container d-flex align-items-center gap-2 py-2">
<small class="text-muted text-nowrap">Default checkpoint:</small>
<select id="defaultCheckpointSelect" class="form-select form-select-sm" style="max-width: 320px;">
<option value="">— workflow default —</option>
{% for ckpt in all_checkpoints %}
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
{% endfor %}
</select>
<small id="checkpointSaveStatus" class="text-muted" style="opacity:0;transition:opacity 0.5s">Saved</small>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
@@ -129,22 +116,44 @@
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
const ckptSelect = document.getElementById('defaultCheckpointSelect');
const saveStatus = document.getElementById('checkpointSaveStatus');
if (ckptSelect) {
ckptSelect.addEventListener('change', () => {
fetch('/set_default_checkpoint', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'checkpoint_path=' + encodeURIComponent(ckptSelect.value)
}).then(() => {
saveStatus.style.opacity = '1';
setTimeout(() => { saveStatus.style.opacity = '0'; }, 1500);
});
});
}
});
// ---- Loaded checkpoint → ComfyUI tooltip ----
(function() {
let _loadedCheckpoint = null;
async function pollLoadedCheckpoint() {
try {
const r = await fetch('/api/comfyui/loaded_checkpoint', { cache: 'no-store' });
const data = await r.json();
_loadedCheckpoint = data.checkpoint || null;
} catch {
_loadedCheckpoint = null;
}
updateComfyTooltip();
}
function updateComfyTooltip() {
const el = document.getElementById('status-comfyui');
if (!el) return;
const dot = el.querySelector('.status-dot');
const online = dot && dot.classList.contains('status-ok');
let text = 'ComfyUI: ' + (online ? 'online' : 'offline');
if (_loadedCheckpoint) {
const parts = _loadedCheckpoint.split(/[/\\]/);
const name = parts[parts.length - 1].replace(/\.safetensors$/, '');
text += '\n' + name;
}
el.setAttribute('data-bs-title', text);
el.setAttribute('title', text);
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) tip.setContent({ '.tooltip-inner': text });
}
// Hook into the existing status polling to refresh tooltip after status changes
window._updateComfyTooltip = updateComfyTooltip;
document.addEventListener('DOMContentLoaded', () => {
pollLoadedCheckpoint();
setInterval(pollLoadedCheckpoint, 30000);
});
})();
</script>
<script>
// ---- Resource delete modal (category galleries) ----
@@ -340,14 +349,15 @@
if (!el) return;
const dot = el.querySelector('.status-dot');
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
if (id === 'status-comfyui' && window._updateComfyTooltip) {
window._updateComfyTooltip();
return;
}
const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
el.setAttribute('data-bs-title', tooltipText);
el.setAttribute('title', tooltipText);
// Refresh tooltip instance if already initialised
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) {
tip.setContent({ '.tooltip-inner': tooltipText });
}
if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
}
async function pollService(svc) {

View File

@@ -43,9 +43,10 @@
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
{% if look.image_path %}
<img src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}" class="img-fluid"
data-preview-path="{{ look.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -89,35 +90,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_look_cover_from_preview', slug=look.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>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 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 : '')">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_look_cover_from_preview', slug=look.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -156,7 +142,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('looks_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -237,9 +223,25 @@
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
let currentJobId = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -272,22 +274,15 @@
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
}
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
});
function showImage(src) {
document.getElementById('modalImage').src = src;
if (src) document.getElementById('modalImage').src = src;
}
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Looks Gallery</h2>
<h2>Looks Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -45,7 +45,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if outfit.image_path %}
<img src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}" class="img-fluid" data-preview-path="{{ outfit.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -86,35 +86,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_outfit_cover_from_preview', slug=outfit.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>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">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_outfit_cover_from_preview', slug=outfit.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -152,7 +137,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -245,7 +230,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -271,9 +257,25 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
let currentJobId = null;
let currentAction = null;
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
// Clicking any image with data-preview-path selects it into the preview pane
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -298,53 +300,34 @@
}
form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') {
return;
}
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
// UI Reset
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST',
body: formData,
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;
}
currentJobId = data.job_id;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
@@ -366,7 +349,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -377,8 +360,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -393,43 +377,46 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
// Phase 1: submit all jobs immediately
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentJobId = null;
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
// Phase 2: poll all in parallel
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) {
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}
}).catch(err => {
done++;
console.error(`Failed for ${char.name}:`, err);
})
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -440,7 +427,7 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Outfit Gallery</h2>
<h2>Outfit Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -56,7 +56,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if scene.image_path %}
<img src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}" class="img-fluid" data-preview-path="{{ scene.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span>
@@ -100,35 +100,20 @@
</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>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
<small>Selected Preview</small>
<form action="{{ url_for('replace_scene_cover_from_preview', slug=scene.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>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">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" 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_scene_cover_from_preview', slug=scene.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">
@@ -145,7 +130,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -255,7 +240,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -281,18 +267,30 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -302,8 +300,7 @@
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -324,26 +321,21 @@
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; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -351,7 +343,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -362,8 +354,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -378,37 +371,37 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -419,10 +412,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
});

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Scene Gallery</h2>
<h2>Scene Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>

View File

@@ -73,6 +73,70 @@
</div>
</div>
<hr>
<!-- Directory Settings -->
<h5 class="mb-3 text-primary">LoRA Directories</h5>
<p class="text-muted small">Absolute paths on disk where LoRA files are scanned for each category.</p>
<div class="mb-3">
<label for="lora_dir_characters" class="form-label">Characters / Looks</label>
<input type="text" class="form-control" id="lora_dir_characters" name="lora_dir_characters"
value="{{ settings.lora_dir_characters or '/ImageModels/lora/Illustrious/Looks' }}">
</div>
<div class="mb-3">
<label for="lora_dir_outfits" class="form-label">Outfits</label>
<input type="text" class="form-control" id="lora_dir_outfits" name="lora_dir_outfits"
value="{{ settings.lora_dir_outfits or '/ImageModels/lora/Illustrious/Clothing' }}">
</div>
<div class="mb-3">
<label for="lora_dir_actions" class="form-label">Actions</label>
<input type="text" class="form-control" id="lora_dir_actions" name="lora_dir_actions"
value="{{ settings.lora_dir_actions or '/ImageModels/lora/Illustrious/Poses' }}">
</div>
<div class="mb-3">
<label for="lora_dir_styles" class="form-label">Styles</label>
<input type="text" class="form-control" id="lora_dir_styles" name="lora_dir_styles"
value="{{ settings.lora_dir_styles or '/ImageModels/lora/Illustrious/Styles' }}">
</div>
<div class="mb-3">
<label for="lora_dir_scenes" class="form-label">Scenes</label>
<input type="text" class="form-control" id="lora_dir_scenes" name="lora_dir_scenes"
value="{{ settings.lora_dir_scenes or '/ImageModels/lora/Illustrious/Backgrounds' }}">
</div>
<div class="mb-3">
<label for="lora_dir_detailers" class="form-label">Detailers</label>
<input type="text" class="form-control" id="lora_dir_detailers" name="lora_dir_detailers"
value="{{ settings.lora_dir_detailers or '/ImageModels/lora/Illustrious/Detailers' }}">
</div>
<hr>
<h5 class="mb-3 text-primary">Checkpoint Directories</h5>
<div class="mb-3">
<label for="checkpoint_dirs" class="form-label">Checkpoint Scan Paths</label>
<input type="text" class="form-control" id="checkpoint_dirs" name="checkpoint_dirs"
value="{{ settings.checkpoint_dirs or '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob' }}">
<div class="form-text">Comma-separated list of directories to scan for checkpoint files.</div>
</div>
<hr>
<h5 class="mb-3 text-primary">Default Checkpoint</h5>
<div class="mb-3">
<label for="default_checkpoint" class="form-label">Active Checkpoint</label>
<div class="input-group">
<select class="form-select" id="default_checkpoint">
<option value="">— workflow default —</option>
{% for ckpt in all_checkpoints %}
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
{% endfor %}
</select>
<span id="ckpt-save-status" class="input-group-text text-success" style="opacity:0;transition:opacity 0.5s">Saved</span>
</div>
<div class="form-text">Sets the checkpoint used for all generation requests. Saved immediately on change.</div>
</div>
<div class="d-grid mt-4">
<button type="submit" class="btn btn-primary btn-lg">Save All Settings</button>
</div>
@@ -152,6 +216,22 @@
}
});
// Default Checkpoint
const defaultCkptSelect = document.getElementById('default_checkpoint');
const ckptSaveStatus = document.getElementById('ckpt-save-status');
if (defaultCkptSelect) {
defaultCkptSelect.addEventListener('change', () => {
fetch('/set_default_checkpoint', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'checkpoint_path=' + encodeURIComponent(defaultCkptSelect.value)
}).then(() => {
ckptSaveStatus.style.opacity = '1';
setTimeout(() => { ckptSaveStatus.style.opacity = '0'; }, 1500);
});
});
}
// Local Model Loading
const connectLocalBtn = document.getElementById('connect-local-btn');
const localModelSelect = document.getElementById('local_model');

View File

@@ -56,7 +56,7 @@
<div class="card mb-4">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if style.image_path %}
<img src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}" class="img-fluid" data-preview-path="{{ style.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span>
@@ -100,35 +100,20 @@
</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>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center p-2" id="preview-card-header">
<small>Selected Preview</small>
<form action="{{ url_for('replace_style_cover_from_preview', slug=style.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>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">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" 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_style_cover_from_preview', slug=style.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">
@@ -145,7 +130,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -247,7 +232,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -273,20 +259,30 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -296,8 +292,7 @@
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -307,9 +302,8 @@
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
@@ -319,26 +313,21 @@
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; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -346,7 +335,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -357,8 +346,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -373,37 +363,37 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) { console.error(`Failed for ${char.name}:`, err); currentJobId = null; }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -414,10 +404,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
});

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Style Gallery</h2>
<h2>Style Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>