12 Commits

Author SHA1 Message Date
Aodhan Collins
1b8a798c31 Add graceful fallback for MCP import in test script
- Add try/except block for MCP package import
- Provide helpful error message when MCP is not installed
- Exit gracefully with status code 1 on import failure

Addresses code review feedback.
2026-03-07 21:13:32 +00:00
Aodhan Collins
d95b81dde5 Multiple bug fixes. 2026-03-06 19:28:50 +00:00
Aodhan Collins
ec08eb5d31 Add Preset Library feature
Presets are saved generation recipes that combine all resource types
(character, outfit, action, style, scene, detailer, look, checkpoint)
with per-field on/off/random toggles. At generation time, entities
marked "random" are picked from the DB and fields marked "random" are
randomly included or excluded.

- Preset model + sync_presets() following existing category pattern
- _resolve_preset_entity() / _resolve_preset_fields() helpers
- Full route set: index, detail, generate, edit, upload, clone, save_json, create (LLM), rescan
- 4 templates: index (gallery), detail (summary + generate), edit (3-way toggle UI), create (LLM form)
- example_01.json reference preset + preset_system.txt LLM prompt
- Presets nav link in layout.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:49:24 +00:00
Aodhan Collins
2c1c3a7ed7 Merge branch 'presets'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:09:00 +00:00
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
Aodhan Collins
9b143e65b1 Redesign gallery grid layout and update CSS across all index pages
Increase card density with more columns per breakpoint (2→6 across sm/xl).
Refactor style.css for consistent card sizing, spacing, and responsive layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 21:43:40 +00:00
Aodhan Collins
27d2a70867 Merge branch 'job-queue'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 02:33:04 +00:00
Aodhan Collins
3c828a170f Add background job queue system for generation
- Implements sequential job queue with background worker thread (_enqueue_job, _queue_worker)
- All generate routes now return job_id instead of prompt_id; frontend polls /api/queue/<id>/status
- Queue management UI in navbar with live badge, job list, pause/resume/remove controls
- Fix: replaced url_for() calls inside finalize callbacks with direct string paths (url_for raises RuntimeError without request context in background threads)
- Batch cover generation now uses two-phase pattern: queue all jobs upfront, then poll concurrently via Promise.all so page navigation doesn't interrupt the process
- Strengths gallery sweep migrated to same two-phase pattern; sgStop() cancels queued jobs server-side
- LoRA weight randomisation via lora_weight_min/lora_weight_max already present in _resolve_lora_weight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 02:32:50 +00:00
50 changed files with 4719 additions and 4130 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. 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)` ### `_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`. 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 ### Characters
- `GET /` — character gallery (index) - `GET /` — character gallery (index)
- `GET /character/<slug>` — character detail with generation UI - `GET /character/<slug>` — character detail with generation UI
- `POST /character/<slug>/generate` — queue generation (AJAX or form) - `POST /character/<slug>/generate` — queue generation (AJAX or form); returns `{"job_id": ...}`
- `POST /character/<slug>/finalize_generation/<prompt_id>` — retrieve image from ComfyUI
- `POST /character/<slug>/replace_cover_from_preview` — promote preview to cover - `POST /character/<slug>/replace_cover_from_preview` — promote preview to cover
- `GET/POST /character/<slug>/edit` — edit character data - `GET/POST /character/<slug>/edit` — edit character data
- `POST /character/<slug>/upload` — upload cover image - `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: Each category follows the same URL pattern:
- `GET /<category>/` — gallery - `GET /<category>/` — gallery
- `GET /<category>/<slug>` — detail + generation UI - `GET /<category>/<slug>` — detail + generation UI
- `POST /<category>/<slug>/generate` — queue generation - `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}`
- `POST /<category>/<slug>/finalize_generation/<prompt_id>` — retrieve image
- `POST /<category>/<slug>/replace_cover_from_preview` - `POST /<category>/<slug>/replace_cover_from_preview`
- `GET/POST /<category>/<slug>/edit` - `GET/POST /<category>/<slug>/edit`
- `POST /<category>/<slug>/upload` - `POST /<category>/<slug>/upload`
@@ -254,15 +276,13 @@ Each category follows the same URL pattern:
- `GET /looks` — gallery - `GET /looks` — gallery
- `GET /look/<slug>` — detail - `GET /look/<slug>` — detail
- `GET/POST /look/<slug>/edit` - `GET/POST /look/<slug>/edit`
- `POST /look/<slug>/generate` - `POST /look/<slug>/generate` — queue generation; returns `{"job_id": ...}`
- `POST /look/<slug>/finalize_generation/<prompt_id>`
- `POST /look/<slug>/replace_cover_from_preview` - `POST /look/<slug>/replace_cover_from_preview`
- `GET/POST /look/create` - `GET/POST /look/create`
- `POST /looks/rescan` - `POST /looks/rescan`
### Generator (Mix & Match) ### Generator (Mix & Match)
- `GET/POST /generator` — freeform generator with multi-select accordion UI - `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 - `POST /generator/preview_prompt` — AJAX: preview composed prompt without generating
### Checkpoints ### Checkpoints
@@ -271,12 +291,17 @@ Each category follows the same URL pattern:
- `POST /checkpoint/<slug>/save_json` - `POST /checkpoint/<slug>/save_json`
- `POST /checkpoints/rescan` - `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 ### Utilities
- `POST /set_default_checkpoint` — save default checkpoint to session - `POST /set_default_checkpoint` — save default checkpoint to session and persist to `comfy_workflow.json`
- `GET /check_status/<prompt_id>` — poll ComfyUI for completion - `GET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}` — AJAX: list items without cover images (sorted by display name)
- `GET /get_missing_{characters,outfits,actions,scenes}` — AJAX: list items without cover images - `POST /generate_missing` — batch generate covers for all characters missing one (uses job queue)
- `POST /generate_missing` — batch generate covers for characters - `POST /clear_all_covers` / `clear_all_{outfit,action,scene,style,detailer,look,checkpoint}_covers`
- `POST /clear_all_covers` / `clear_all_{outfit,action,scene}_covers`
- `GET /gallery` — global image gallery browsing `static/uploads/` - `GET /gallery` — global image gallery browsing `static/uploads/`
- `GET/POST /settings` — LLM provider configuration - `GET/POST /settings` — LLM provider configuration
- `POST /resource/<category>/<slug>/delete` — soft (JSON only) or hard (JSON + safetensors) delete - `POST /resource/<category>/<slug>/delete` — soft (JSON only) or hard (JSON + safetensors) delete
@@ -295,7 +320,12 @@ Each category follows the same URL pattern:
- `initJsonEditor(saveUrl)` — shared JSON editor modal (simple form + raw textarea tabs) - `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. - 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. - **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.
- **Batch generation** (library pages): Uses a two-phase pattern:
1. **Queue phase**: All jobs are submitted upfront via sequential fetch calls, collecting job IDs
2. **Poll phase**: All jobs are polled concurrently via `Promise.all()`, updating UI as each completes
3. **Progress tracking**: Displays currently processing items in real-time using a `Set` to track active jobs
4. **Sorting**: All batch operations sort items by display `name` (not `filename`) for better UX
--- ---
@@ -374,6 +404,8 @@ The Flask filesystem session stores:
## Running the App ## Running the App
### Directly (development)
```bash ```bash
cd /mnt/alexander/Projects/character-browser cd /mnt/alexander/Projects/character-browser
source venv/bin/activate source venv/bin/activate
@@ -384,6 +416,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()`. 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 ## Common Pitfalls
@@ -396,3 +447,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. - **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`. - **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. - **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 ## 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. 1. **Clone the repository** to your local machine.
2. **Configure Paths**: Open `app.py` and update the following variables to match your system: 2. **Configure Paths**: Open `app.py` and update the following variables to match your system:
```python ```python

2547
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
}, },
"4": { "4": {
"inputs": { "inputs": {
"ckpt_name": "Noob/oneObsession_v19Atypical.safetensors" "ckpt_name": ""
}, },
"class_type": "CheckpointLoaderSimple" "class_type": "CheckpointLoaderSimple"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"character_id": "delinquent_mother_flim13", "character_id": "delinquent_mother_flim13",
"character_name": "Delinquent Mother", "character_name": "Gyaru Mother",
"identity": { "identity": {
"base_specs": "1girl, milf, gyaru, tall", "base_specs": "1girl, milf, gyaru, tall",
"hair": "blonde hair, long hair", "hair": "blonde hair, long hair",

View File

@@ -22,7 +22,7 @@
"default": { "default": {
"full_body": "", "full_body": "",
"headwear": "", "headwear": "",
"top": "black crop top, blue and silver motorcycle jacket", "top": "black crop top, blue and silver jacket",
"bottom": "black leather pants", "bottom": "black leather pants",
"legwear": "", "legwear": "",
"footwear": "blue sneakers", "footwear": "blue sneakers",

View File

@@ -1,11 +1,11 @@
{ {
"checkpoint_path": "Illustrious/zukiNewCuteILL_newV20.safetensors",
"checkpoint_name": "zukiNewCuteILL_newV20.safetensors",
"base_positive": "anime",
"base_negative": "text, logo", "base_negative": "text, logo",
"steps": 25, "base_positive": "anime, cute, loli, moe",
"cfg": 5, "cfg": 5,
"scheduler": "normal", "checkpoint_name": "zukiNewCuteILL_newV20.safetensors",
"checkpoint_path": "Illustrious/zukiNewCuteILL_newV20.safetensors",
"sampler_name": "euler_ancestral", "sampler_name": "euler_ancestral",
"scheduler": "normal",
"steps": 25,
"vae": "integrated" "vae": "integrated"
} }

View File

@@ -1,23 +1,13 @@
{ {
"outfit_id": "golddripnunchaindresslingerieill",
"outfit_name": "Golddripnunchaindresslingerieill",
"wardrobe": {
"full_body": "revealing nun dress with gold drip accents",
"headwear": "nun veil, jewelry",
"top": "lingerie top, gold chains",
"bottom": "skirt, gold trim",
"legwear": "thighhighs, garter straps",
"footwear": "heels",
"hands": "",
"accessories": "gold chains, cross necklace, body chain"
},
"lora": { "lora": {
"lora_name": "Illustrious/Clothing/GoldDripNunChainDressLingerieILL.safetensors", "lora_name": "",
"lora_triggers": "",
"lora_weight": 0.8, "lora_weight": 0.8,
"lora_triggers": "GoldDripNunChainDressLingerieILL", "lora_weight_max": 0.8,
"lora_weight_min": 0.8, "lora_weight_min": 0.8
"lora_weight_max": 0.8
}, },
"outfit_id": "golddripnunchaindresslingerieill",
"outfit_name": "Nun (with Gold)",
"tags": [ "tags": [
"nun", "nun",
"veil", "veil",
@@ -33,5 +23,15 @@
"dripping", "dripping",
"gold", "gold",
"body_chain" "body_chain"
] ],
"wardrobe": {
"accessories": "gold chains, cross necklace, body chain",
"bottom": "skirt, gold trim",
"footwear": "heels",
"full_body": "revealing nun dress with gold drip accents",
"hands": "",
"headwear": "nun veil, jewelry",
"legwear": "thighhighs, garter straps",
"top": "lingerie top, gold chains"
}
} }

View File

@@ -1,9 +1,7 @@
{ {
"character_id": null,
"look_id": "jn_tron_bonne_illus", "look_id": "jn_tron_bonne_illus",
"look_name": "Jn Tron Bonne Illus", "look_name": "Jn Tron Bonne Illus",
"character_id": "",
"positive": "tron_bonne_(mega_man), brown_hair, short_hair, spiked_hair, goggles_on_head, pink_jacket, crop_top, midriff, navel, skull_print, pink_shorts, boots, large_earrings, servbot_(mega_man)",
"negative": "pubic hair, 3d, realistic, loli, censored, bad anatomy, sketch, monochrome",
"lora": { "lora": {
"lora_name": "Illustrious/Looks/JN_Tron_Bonne_Illus.safetensors", "lora_name": "Illustrious/Looks/JN_Tron_Bonne_Illus.safetensors",
"lora_weight": 0.8, "lora_weight": 0.8,
@@ -11,6 +9,8 @@
"lora_weight_min": 0.8, "lora_weight_min": 0.8,
"lora_weight_max": 0.8 "lora_weight_max": 0.8
}, },
"negative": "pubic hair, 3d, realistic, loli, censored, bad anatomy, sketch, monochrome",
"positive": "tron_bonne_(mega_man), brown_hair, short_hair, spiked_hair, purple cropped jacket, pantyhose, metal panties, short pink dress, boots, skull_earrings, servbot_(mega_man)",
"tags": [ "tags": [
"tron_bonne_(mega_man)", "tron_bonne_(mega_man)",
"goggles_on_head", "goggles_on_head",

View File

@@ -0,0 +1,84 @@
{
"preset_id": "example_01",
"preset_name": "Example Preset",
"character": {
"character_id": "aerith_gainsborough",
"use_lora": true,
"fields": {
"identity": {
"base_specs": true,
"hair": true,
"eyes": true,
"hands": true,
"arms": false,
"torso": true,
"pelvis": false,
"legs": false,
"feet": false,
"extra": "random"
},
"defaults": {
"expression": "random",
"pose": false,
"scene": false
},
"wardrobe": {
"outfit": "default",
"fields": {
"full_body": true,
"headwear": "random",
"top": true,
"bottom": true,
"legwear": true,
"footwear": true,
"hands": false,
"gloves": false,
"accessories": "random"
}
}
}
},
"outfit": {
"outfit_id": null,
"use_lora": true
},
"action": {
"action_id": "random",
"use_lora": true,
"fields": {
"full_body": true,
"additional": true,
"head": true,
"eyes": false,
"arms": true,
"hands": true
}
},
"style": {
"style_id": "random",
"use_lora": true
},
"scene": {
"scene_id": "random",
"use_lora": true,
"fields": {
"background": true,
"foreground": "random",
"furniture": "random",
"colors": false,
"lighting": true,
"theme": false
}
},
"detailer": {
"detailer_id": null,
"use_lora": true
},
"look": {
"look_id": null
},
"checkpoint": {
"checkpoint_path": null
},
"tags": []
}

29
data/presets/preset.json Normal file
View File

@@ -0,0 +1,29 @@
{
"preset_id": "example_01",
"preset_name": "Example Preset",
"prompt":{
"character": {
"character_id": "aerith_gainsborough",
"identity": {
"base_specs": true,
"hair": true,
"eyes": true
...
},
"defaults": {
"expression": false,
"pose": false,
"scene": false
},
"wardrobe": {
"outfit_id": "default",
"outfit": {
"headwear": true,
"accessories": true
...
}
},
"use_lora": true
}
}
}

View File

@@ -0,0 +1,58 @@
You are a JSON generator for generation preset profiles in GAZE, an AI image generation tool. Output ONLY valid JSON matching the exact structure below. Do not wrap in markdown blocks.
A preset is a complete generation recipe that specifies which resources to use and which prompt fields to include. Every entity can be set to a specific ID, "random" (pick randomly at generation time), or null (not used). Every field toggle can be true (always include), false (always exclude), or "random" (randomly decide each generation).
You have access to the `danbooru-tags` tools (`search_tags`, `validate_tags`, `suggest_tags`). Use them only if you are populating the `tags` array with explicit prompt tags. Do not use them for entity IDs or toggle values.
Structure:
{
"preset_id": "WILL_BE_REPLACED",
"preset_name": "WILL_BE_REPLACED",
"character": {
"character_id": "specific_id | random | null",
"use_lora": true,
"fields": {
"identity": {
"base_specs": true, "hair": true, "eyes": true, "hands": true,
"arms": false, "torso": true, "pelvis": false, "legs": false,
"feet": false, "extra": "random"
},
"defaults": {
"expression": "random",
"pose": false,
"scene": false
},
"wardrobe": {
"outfit": "default",
"fields": {
"full_body": true, "headwear": "random", "top": true,
"bottom": true, "legwear": true, "footwear": true,
"hands": false, "gloves": false, "accessories": "random"
}
}
}
},
"outfit": { "outfit_id": "specific_id | random | null", "use_lora": true },
"action": {
"action_id": "specific_id | random | null",
"use_lora": true,
"fields": { "full_body": true, "additional": true, "head": true, "eyes": false, "arms": true, "hands": true }
},
"style": { "style_id": "specific_id | random | null", "use_lora": true },
"scene": {
"scene_id": "specific_id | random | null",
"use_lora": true,
"fields": { "background": true, "foreground": "random", "furniture": "random", "colors": false, "lighting": true, "theme": false }
},
"detailer": { "detailer_id": "specific_id | random | null", "use_lora": true },
"look": { "look_id": "specific_id | random | null" },
"checkpoint": { "checkpoint_path": "specific_path | random | null" },
"tags": []
}
Guidelines:
- Set entity IDs to "random" when the user wants variety, null when they want to skip that resource, or a specific ID string when they reference something by name.
- Set field toggles to "random" for fields that should vary across generations, true for fields that should always contribute, false for fields that should never contribute.
- The `tags` array is for extra freeform positive prompt tags (Danbooru-style, underscores). Validate them with the tools.
- Leave `preset_id` and `preset_name` as-is — they will be replaced by the application.
- Output ONLY valid JSON. No explanations, no markdown fences.

View File

@@ -2,8 +2,8 @@
"style_id": "7b_style", "style_id": "7b_style",
"style_name": "7B Dream", "style_name": "7B Dream",
"style": { "style": {
"artist_name": "7b_Dream", "artist_name": "7b",
"artistic_style": "3d" "artistic_style": "3d, blender, semi-realistic"
}, },
"lora": { "lora": {
"lora_name": "Illustrious/Styles/7b-style.safetensors", "lora_name": "Illustrious/Styles/7b-style.safetensors",

View File

@@ -1,15 +0,0 @@
{
"style_id": "bckiwi_3d_style_il_2_7_rank16_fp16",
"style_name": "Bckiwi 3D Style Il 2 7 Rank16 Fp16",
"style": {
"artist_name": "",
"artistic_style": ""
},
"lora": {
"lora_name": "Illustrious/Styles/BCkiwi_3D_style_IL_2.7_rank16_fp16.safetensors",
"lora_weight": 1.0,
"lora_triggers": "BCkiwi_3D_style_IL_2.7_rank16_fp16",
"lora_weight_min": 1.0,
"lora_weight_max": 1.0
}
}

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
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"
# Enable debug logging
FLASK_DEBUG: "1"
LOG_LEVEL: "DEBUG"
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

0
launch.sh Normal file → Executable file
View File

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

@@ -125,6 +125,19 @@ class Checkpoint(db.Model):
def __repr__(self): def __repr__(self):
return f'<Checkpoint {self.checkpoint_id}>' return f'<Checkpoint {self.checkpoint_id}>'
class Preset(db.Model):
id = db.Column(db.Integer, primary_key=True)
preset_id = db.Column(db.String(100), unique=True, nullable=False)
slug = db.Column(db.String(100), unique=True, nullable=False)
filename = db.Column(db.String(255), nullable=True)
name = db.Column(db.String(100), nullable=False)
data = db.Column(db.JSON, nullable=False)
image_path = db.Column(db.String(255), nullable=True)
def __repr__(self):
return f'<Preset {self.preset_id}>'
class Settings(db.Model): class Settings(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
llm_provider = db.Column(db.String(50), default='openrouter') # 'openrouter', 'ollama', 'lmstudio' llm_provider = db.Column(db.String(50), default='openrouter') # 'openrouter', 'ollama', 'lmstudio'
@@ -132,6 +145,17 @@ class Settings(db.Model):
openrouter_model = db.Column(db.String(100), default='google/gemini-2.0-flash-001') 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_base_url = db.Column(db.String(255), nullable=True)
local_model = db.Column(db.String(100), 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')
# Default checkpoint path (persisted across server restarts)
default_checkpoint = db.Column(db.String(500), nullable=True)
def __repr__(self): def __repr__(self):
return '<Settings>' return '<Settings>'

View File

@@ -1,42 +1,78 @@
/* ============================================================ /* ============================================================
Character Browser — Dark Theme (Indigo / Violet) GAZE — Character Browser Design System
Space Grotesk (display) + Inter (body)
Deep dark, violet-indigo accent palette
============================================================ */ ============================================================ */
/* --- Variables ------------------------------------------------ */ /* --- Variables ------------------------------------------------ */
:root { :root {
--bg-base: #0f0f1a; /* Backgrounds */
--bg-card: #1a1a2e; --bg-base: #07070f;
--bg-raised: #222240; --bg-card: #0c0c1c;
--bg-input: #16162a; --bg-raised: #111128;
--accent: #6c63ff; --bg-input: #09091a;
--accent-dim: #4f46e5;
--accent-glow: rgba(108, 99, 255, 0.22); /* Accent — violet */
--border: #2d2d5e; --accent: #8b7eff;
--border-light: #3a3a6a; --accent-dim: #6c5ce7;
--text: #e2e2f0; --accent-bright:#b0a8ff;
--text-muted: #8888aa; --accent-glow: rgba(139, 126, 255, 0.14);
--text-dim: #5555aa; --accent-glow-strong: rgba(139, 126, 255, 0.32);
--success: #22c55e;
--danger: #ef4444; /* Borders */
--warning: #f59e0b; --border: #16163a;
--info: #38bdf8; --border-light: #21214a;
--radius: 10px; --border-focus: #5548c8;
/* Text */
--text: #e8e8f5;
--text-muted: #6a6a9a;
--text-dim: #35356a;
/* Status */
--success: #34d399;
--danger: #f87171;
--warning: #fbbf24;
--info: #60c8ff;
/* Shape */
--radius: 12px;
--radius-sm: 8px;
/* Fonts */
--font-display: 'Space Grotesk', 'Inter', system-ui, sans-serif;
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
} }
/* --- Base ----------------------------------------------------- */ /* --- Base ----------------------------------------------------- */
body { body {
background-color: var(--bg-base); background-color: var(--bg-base);
/* Subtle radial accent glow — premium depth effect */
background-image:
radial-gradient(ellipse at 18% 0%, rgba(139, 126, 255, 0.06) 0%, transparent 55%),
radial-gradient(ellipse at 82% 100%, rgba(192, 132, 252, 0.05) 0%, transparent 55%);
background-attachment: fixed;
color: var(--text); color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: var(--font-body);
font-size: 0.9375rem;
line-height: 1.6;
min-height: 100vh; min-height: 100vh;
} }
a { color: var(--accent); } a { color: var(--accent-bright); text-decoration: none; }
a:hover { color: #9d98ff; } a:hover { color: #c4b5fd; }
h1, h2, h3, h4 {
font-family: var(--font-display);
font-weight: 600;
color: var(--text);
letter-spacing: -0.02em;
}
h5, h6 { color: var(--text); }
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--bg-card); } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); } ::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
@@ -44,83 +80,160 @@ a:hover { color: #9d98ff; }
Navbar Navbar
============================================================ */ ============================================================ */
.navbar { .navbar {
background: rgba(15, 15, 26, 0.95) !important; background: rgba(7, 7, 15, 0.97) !important;
backdrop-filter: blur(12px); backdrop-filter: blur(24px) saturate(1.3);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border) !important;
padding: 0.55rem 0; padding: 0.5rem 0;
} }
.navbar-brand { .navbar-brand {
font-family: var(--font-display);
font-weight: 700; font-weight: 700;
font-size: 1.05rem; font-size: 1.05rem;
letter-spacing: 0.12em; letter-spacing: 0.18em;
text-transform: uppercase; text-transform: uppercase;
background: linear-gradient(90deg, #a78bfa, #6c63ff, #c084fc); background: linear-gradient(135deg, #c084fc 0%, #8b7eff 45%, #60a5fa 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.navbar-brand:hover { .navbar-brand:hover {
background: linear-gradient(90deg, #c4b5fd, #818cf8, #d8b4fe); background: linear-gradient(135deg, #d8b4fe 0%, #b0a8ff 45%, #93c5fd 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
} }
.navbar-logo { .navbar-logo {
height: 28px; height: 26px;
width: auto; width: auto;
} }
/* All nav outline-light buttons become understated link-style items */ /* Nav links — borderless, subtle hover */
.navbar .btn-outline-light { .navbar .btn-outline-light {
border: none; border: none;
color: var(--text-muted) !important; color: var(--text-muted) !important;
background: transparent; background: transparent;
font-size: 0.82rem; font-size: 0.79rem;
padding: 0.28rem 0.6rem; font-weight: 500;
border-radius: 6px; padding: 0.28rem 0.58rem;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
letter-spacing: 0.01em;
} }
.navbar .btn-outline-light:hover, .navbar .btn-outline-light:hover,
.navbar .btn-outline-light:focus { .navbar .btn-outline-light:focus {
color: var(--text) !important; color: var(--text) !important;
background: rgba(255, 255, 255, 0.07); background: rgba(139, 126, 255, 0.08);
box-shadow: none; box-shadow: none;
} }
/* Generator and Gallery get accent tint */
.navbar .btn-outline-light[href="/generator"], .navbar .btn-outline-light[href="/generator"],
.navbar .btn-outline-light[href="/gallery"] { .navbar .btn-outline-light[href="/gallery"] {
color: #9d98ff !important; color: var(--accent-bright) !important;
} }
.navbar .btn-outline-light[href="/generator"]:hover, .navbar .btn-outline-light[href="/generator"]:hover,
.navbar .btn-outline-light[href="/gallery"]:hover { .navbar .btn-outline-light[href="/gallery"]:hover {
background: var(--accent-glow); background: var(--accent-glow);
color: #b8b4ff !important; color: #c4b5fd !important;
} }
/* Create Character */
.navbar .btn-outline-success { .navbar .btn-outline-success {
border: 1px solid rgba(34, 197, 94, 0.5); border: 1px solid rgba(52, 211, 153, 0.35);
color: var(--success) !important; color: var(--success) !important;
font-size: 0.82rem; font-size: 0.79rem;
font-weight: 500;
padding: 0.28rem 0.7rem; padding: 0.28rem 0.7rem;
border-radius: 6px; border-radius: var(--radius-sm);
transition: all 0.15s; transition: all 0.15s;
} }
.navbar .btn-outline-success:hover { .navbar .btn-outline-success:hover {
background: rgba(34, 197, 94, 0.12); background: rgba(52, 211, 153, 0.1);
border-color: var(--success); border-color: var(--success);
box-shadow: none; box-shadow: none;
color: var(--success) !important; color: var(--success) !important;
} }
/* Vertical divider in navbar */
.navbar .vr { .navbar .vr {
background-color: var(--border); background-color: var(--border-light);
opacity: 1; opacity: 1;
} }
/* Queue button */
.queue-btn {
position: relative;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 0.95rem;
padding: 0.2rem 0.48rem;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
line-height: 1;
}
.queue-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
.queue-btn-active { color: var(--accent-bright) !important; }
.queue-badge {
position: absolute;
top: -2px;
right: -4px;
background: var(--accent);
color: #fff;
font-size: 0.58rem;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
line-height: 1;
}
.queue-icon { font-size: 0.88rem; }
/* Queue status dots */
.queue-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
.queue-status-pending { background: var(--text-muted); }
.queue-status-processing { background: var(--warning); animation: pulse 1s infinite; }
.queue-status-paused { background: var(--text-dim); }
.queue-status-done { background: var(--success); }
.queue-status-failed { background: var(--danger); }
.queue-status-removed { background: var(--border-light); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.btn-xs {
padding: 0.1rem 0.35rem;
font-size: 0.7rem;
border-radius: 4px;
line-height: 1.4;
}
/* ============================================================
Checkpoint Bar
============================================================ */
.default-checkpoint-bar {
background:
linear-gradient(to right,
rgba(139, 126, 255, 0.04) 0%,
transparent 25%,
transparent 75%,
rgba(139, 126, 255, 0.04) 100%);
border-bottom: 1px solid var(--border) !important;
}
/* ============================================================ /* ============================================================
Cards Cards
============================================================ */ ============================================================ */
@@ -136,10 +249,10 @@ a:hover { color: #9d98ff; }
border-bottom: 1px solid var(--border) !important; border-bottom: 1px solid var(--border) !important;
color: var(--text) !important; color: var(--text) !important;
font-weight: 600; font-weight: 600;
font-size: 0.88rem; font-size: 0.84rem;
letter-spacing: 0.01em;
} }
/* Override Bootstrap bg utility classes on card-header */
.card-header.bg-primary, .card-header.bg-primary,
.card-header.bg-dark, .card-header.bg-dark,
.card-header.bg-secondary, .card-header.bg-secondary,
@@ -156,19 +269,76 @@ a:hover { color: #9d98ff; }
.card-body { color: var(--text); } .card-body { color: var(--text); }
/* Character / category card hover */ /* ============================================================
Gallery Cards (.character-card)
============================================================ */
.character-card { .character-card {
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; overflow: hidden;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
display: flex !important;
flex-direction: column;
} }
.character-card:hover { .character-card:hover {
transform: translateY(-4px); transform: translateY(-5px);
box-shadow: 0 8px 32px var(--accent-glow); box-shadow: 0 20px 60px rgba(4, 4, 10, 0.7), 0 0 0 1px var(--accent);
border-color: var(--accent) !important; border-color: var(--accent) !important;
} }
/* Gallery card image container — portrait 2:3 ratio */
.character-card .img-container {
aspect-ratio: 2 / 3;
height: auto;
min-height: unset;
flex-shrink: 0;
}
.character-card .img-container img {
transition: transform 0.4s ease;
}
.character-card:hover .img-container img {
transform: scale(1.07);
}
/* Subtle accent tint on hover via pseudo-overlay */
.character-card:hover .img-container::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 55%, rgba(108, 92, 231, 0.12) 100%);
pointer-events: none;
z-index: 1;
}
/* Compact name strip */
.character-card .card-body {
padding: 0.55rem 0.7rem 0.4rem;
flex: 1;
}
.character-card .card-title {
font-family: var(--font-display);
font-size: 0.84rem;
font-weight: 600;
margin-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.01em;
}
.character-card .card-text {
display: none; /* Hide verbose prompt text — cleaner gallery look */
}
.character-card .card-footer {
padding: 0.28rem 0.7rem;
}
.character-card .card-footer small {
font-size: 0.66rem;
font-family: monospace;
color: var(--text-dim) !important;
}
/* ============================================================ /* ============================================================
Image container Image container (detail pages and standalone use)
============================================================ */ ============================================================ */
.img-container { .img-container {
height: 300px; height: 300px;
@@ -178,6 +348,7 @@ a:hover { color: #9d98ff; }
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius) var(--radius) 0 0; border-radius: var(--radius) var(--radius) 0 0;
position: relative;
} }
.img-container img { .img-container img {
width: 100%; width: 100%;
@@ -185,6 +356,34 @@ a:hover { color: #9d98ff; }
object-fit: cover; object-fit: cover;
} }
/* Assignment badge — shows count of characters using this resource */
.assignment-badge {
position: absolute;
top: 8px;
right: 8px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dim) 100%);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
min-width: 22px;
height: 22px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
line-height: 1;
z-index: 2;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
border: 1.5px solid rgba(255, 255, 255, 0.15);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.character-card:hover .assignment-badge {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(139, 126, 255, 0.5);
}
/* Generator result container */ /* Generator result container */
#result-container { #result-container {
background-color: var(--bg-raised) !important; background-color: var(--bg-raised) !important;
@@ -197,15 +396,16 @@ a:hover { color: #9d98ff; }
.form-control, .form-control,
.form-select { .form-select {
background-color: var(--bg-input) !important; background-color: var(--bg-input) !important;
border-color: var(--border) !important; border-color: var(--border-light) !important;
color: var(--text) !important; color: var(--text) !important;
border-radius: 6px; border-radius: var(--radius-sm);
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.875rem;
} }
.form-control:focus, .form-control:focus,
.form-select:focus { .form-select:focus {
border-color: var(--accent) !important; border-color: var(--border-focus) !important;
box-shadow: 0 0 0 3px var(--accent-glow) !important; box-shadow: 0 0 0 3px rgba(85, 72, 200, 0.22) !important;
background-color: var(--bg-input) !important; background-color: var(--bg-input) !important;
color: var(--text) !important; color: var(--text) !important;
} }
@@ -214,10 +414,11 @@ a:hover { color: #9d98ff; }
.form-select:disabled { .form-select:disabled {
background-color: var(--bg-raised) !important; background-color: var(--bg-raised) !important;
color: var(--text-muted) !important; color: var(--text-muted) !important;
opacity: 0.65;
} }
.form-label { color: var(--text-muted); font-size: 0.82rem; font-weight: 500; } .form-label { color: var(--text-muted); font-size: 0.8rem; font-weight: 500; margin-bottom: 0.3rem; }
.form-text { color: var(--text-dim) !important; } .form-text { color: var(--text-dim) !important; font-size: 0.77rem; }
.form-check-input { .form-check-input {
background-color: var(--bg-input); background-color: var(--bg-input);
@@ -227,8 +428,11 @@ a:hover { color: #9d98ff; }
background-color: var(--accent); background-color: var(--accent);
border-color: var(--accent); border-color: var(--accent);
} }
.form-check-input:focus { box-shadow: 0 0 0 3px var(--accent-glow); } .form-check-input:focus {
.form-check-label { color: var(--text); } box-shadow: 0 0 0 3px rgba(85, 72, 200, 0.22);
border-color: var(--border-focus);
}
.form-check-label { color: var(--text); font-size: 0.875rem; }
option { background-color: var(--bg-raised); color: var(--text); } option { background-color: var(--bg-raised); color: var(--text); }
@@ -236,22 +440,24 @@ option { background-color: var(--bg-raised); color: var(--text); }
Buttons Buttons
============================================================ */ ============================================================ */
.btn-primary { .btn-primary {
background-color: var(--accent); background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dim) 100%);
border-color: var(--accent-dim); border: 1px solid var(--accent-dim);
color: #fff; color: #fff;
font-weight: 500;
} }
.btn-primary:hover, .btn-primary:hover,
.btn-primary:active, .btn-primary:active,
.btn-primary:focus { .btn-primary:focus {
background-color: var(--accent-dim); background: linear-gradient(135deg, var(--accent-bright) 0%, var(--accent) 100%);
border-color: #3730a3; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow); box-shadow: 0 0 0 3px rgba(139, 126, 255, 0.22);
color: #fff; color: #fff;
} }
.btn-outline-primary { .btn-outline-primary {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
font-weight: 500;
} }
.btn-outline-primary:hover, .btn-outline-primary:hover,
.btn-outline-primary:active { .btn-outline-primary:active {
@@ -264,9 +470,10 @@ option { background-color: var(--bg-raised); color: var(--text); }
background-color: var(--bg-raised); background-color: var(--bg-raised);
border-color: var(--border-light); border-color: var(--border-light);
color: var(--text); color: var(--text);
font-weight: 500;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #2a2a50; background-color: #1c1c38;
border-color: var(--border-light); border-color: var(--border-light);
color: var(--text); color: var(--text);
} }
@@ -274,6 +481,7 @@ option { background-color: var(--bg-raised); color: var(--text); }
.btn-outline-secondary { .btn-outline-secondary {
border-color: var(--border-light); border-color: var(--border-light);
color: var(--text-muted); color: var(--text-muted);
font-weight: 500;
} }
.btn-outline-secondary:hover, .btn-outline-secondary:hover,
.btn-outline-secondary:active { .btn-outline-secondary:active {
@@ -284,36 +492,36 @@ option { background-color: var(--bg-raised); color: var(--text); }
/* Active resolution preset */ /* Active resolution preset */
.btn-secondary.preset-btn { .btn-secondary.preset-btn {
background-color: var(--accent); background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dim) 100%);
border-color: var(--accent-dim); border-color: var(--accent-dim);
color: #fff; color: #fff;
} }
.btn-outline-success { border-color: var(--success); color: var(--success); } .btn-outline-success { border-color: var(--success); color: var(--success); font-weight: 500; }
.btn-outline-success:hover { background-color: rgba(34,197,94,.12); border-color: var(--success); color: var(--success); } .btn-outline-success:hover { background-color: rgba(52,211,153,.1); border-color: var(--success); color: var(--success); }
.btn-success { background-color: var(--success); border-color: #16a34a; color: #fff; } .btn-success { background-color: #059669; border-color: #047857; color: #fff; font-weight: 500; }
.btn-success:hover { background-color: #16a34a; border-color: #15803d; color: #fff; } .btn-success:hover { background-color: #047857; border-color: #065f46; color: #fff; }
.btn-outline-danger { border-color: var(--danger); color: var(--danger); } .btn-outline-danger { border-color: var(--danger); color: var(--danger); font-weight: 500; }
.btn-outline-danger:hover { background-color: rgba(239,68,68,.12); border-color: var(--danger); color: var(--danger); } .btn-outline-danger:hover { background-color: rgba(248,113,113,.1); border-color: var(--danger); color: var(--danger); }
.btn-danger { background-color: var(--danger); border-color: #dc2626; color: #fff; } .btn-danger { background-color: #dc2626; border-color: #b91c1c; color: #fff; font-weight: 500; }
.btn-danger:hover { background-color: #dc2626; border-color: #b91c1c; color: #fff; } .btn-danger:hover { background-color: #b91c1c; border-color: #991b1b; color: #fff; }
.btn-outline-warning { border-color: var(--warning); color: var(--warning); } .btn-outline-warning { border-color: var(--warning); color: var(--warning); font-weight: 500; }
.btn-outline-warning:hover { background-color: rgba(245,158,11,.12); border-color: var(--warning); color: var(--warning); } .btn-outline-warning:hover { background-color: rgba(251,191,36,.1); border-color: var(--warning); color: var(--warning); }
.btn-light, .btn-outline-light { .btn-light, .btn-outline-light {
background-color: rgba(255,255,255,.08); background-color: rgba(255,255,255,.06);
border-color: var(--border-light); border-color: var(--border-light);
color: var(--text); color: var(--text);
font-weight: 500;
} }
.btn-light:hover, .btn-outline-light:hover { .btn-light:hover, .btn-outline-light:hover {
background-color: rgba(255,255,255,.14); background-color: rgba(255,255,255,.1);
color: var(--text); color: var(--text);
} }
/* Close button override (visible on dark bg) */ .btn-close { filter: invert(1) brightness(0.7); }
.btn-close { filter: invert(1) brightness(0.8); }
/* ============================================================ /* ============================================================
Accordion Accordion
@@ -322,60 +530,57 @@ option { background-color: var(--bg-raised); color: var(--text); }
background-color: var(--bg-card) !important; background-color: var(--bg-card) !important;
border-color: var(--border) !important; border-color: var(--border) !important;
} }
.accordion-button { .accordion-button {
background-color: var(--bg-raised) !important; background-color: var(--bg-raised) !important;
color: var(--text) !important; color: var(--text) !important;
font-size: 0.88rem; font-size: 0.875rem;
font-weight: 500;
} }
.accordion-button:not(.collapsed) { .accordion-button:not(.collapsed) {
background-color: #252450 !important; background-color: #181835 !important;
color: var(--text) !important; color: var(--accent-bright) !important;
box-shadow: inset 0 -1px 0 var(--border); box-shadow: inset 0 -1px 0 var(--border);
} }
/* Make the chevron arrow visible on dark bg */
.accordion-button::after, .accordion-button::after,
.accordion-button:not(.collapsed)::after { .accordion-button:not(.collapsed)::after {
filter: invert(1) brightness(0.75); filter: invert(1) brightness(0.6);
} }
.accordion-button:focus { box-shadow: none; } .accordion-button:focus { box-shadow: none; }
.accordion-body { background-color: var(--bg-card); padding: 0.5rem; } .accordion-body { background-color: var(--bg-card); padding: 0.5rem; }
/* Mix & match list items */ /* Mix & match list items */
.mix-item { user-select: none; border-radius: 6px; } .mix-item { user-select: none; border-radius: var(--radius-sm); }
.mix-item:hover { background-color: rgba(108, 99, 255, 0.12) !important; } .mix-item:hover { background-color: rgba(139, 126, 255, 0.1) !important; }
/* N/A placeholder for items without images */
.mix-item .bg-light { .mix-item .bg-light {
background-color: var(--bg-raised) !important; background-color: var(--bg-raised) !important;
color: var(--text-dim) !important; color: var(--text-dim) !important;
} }
/* ============================================================ /* ============================================================
Progress bars Progress Bars
============================================================ */ ============================================================ */
.progress { .progress {
background-color: var(--bg-raised); background-color: var(--bg-raised);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 999px;
} }
.progress-bar { .progress-bar {
background-color: var(--accent); background: linear-gradient(90deg, var(--accent-dim), var(--accent));
border-radius: 8px; border-radius: 999px;
transition: width 0.4s ease-in-out; transition: width 0.4s ease-in-out;
} }
.progress-bar.bg-success { background-color: var(--success) !important; } .progress-bar.bg-success { background: var(--success) !important; }
.progress-bar.bg-info { background-color: var(--info) !important; } .progress-bar.bg-info { background: var(--info) !important; }
/* ============================================================ /* ============================================================
Badges Badges
============================================================ */ ============================================================ */
.badge.bg-primary { background-color: var(--accent) !important; } .badge.bg-primary { background-color: var(--accent) !important; }
.badge.bg-secondary { background-color: #2e2e52 !important; color: var(--text-muted) !important; } .badge.bg-secondary { background-color: #1b1b40 !important; color: var(--text-muted) !important; }
.badge.bg-info { background-color: var(--info) !important; color: #0f172a !important; } .badge.bg-info { background-color: var(--info) !important; color: #0c1a2b !important; }
.badge.bg-success { background-color: var(--success) !important; } .badge.bg-success { background-color: var(--success) !important; color: #012218 !important; }
.badge.bg-danger { background-color: var(--danger) !important; } .badge.bg-danger { background-color: var(--danger) !important; }
.badge.bg-warning { background-color: var(--warning) !important; color: #0f172a !important; } .badge.bg-warning { background-color: var(--warning) !important; color: #1a0f00 !important; }
.badge.bg-light { background-color: var(--bg-raised) !important; color: var(--text-muted) !important; border-color: var(--border) !important; } .badge.bg-light { background-color: var(--bg-raised) !important; color: var(--text-muted) !important; border-color: var(--border) !important; }
/* ============================================================ /* ============================================================
@@ -383,39 +588,50 @@ option { background-color: var(--bg-raised); color: var(--text); }
============================================================ */ ============================================================ */
.modal-content { .modal-content {
background-color: var(--bg-card); background-color: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border-light);
border-radius: var(--radius); border-radius: var(--radius);
color: var(--text); color: var(--text);
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.85), 0 0 0 1px rgba(139, 126, 255, 0.08);
} }
.modal-header { .modal-header {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background-color: var(--bg-raised); background-color: var(--bg-raised);
border-radius: var(--radius) var(--radius) 0 0; border-radius: var(--radius) var(--radius) 0 0;
padding: 1rem 1.25rem;
} }
.modal-footer { .modal-footer {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background-color: var(--bg-raised); background-color: var(--bg-raised);
border-radius: 0 0 var(--radius) var(--radius); border-radius: 0 0 var(--radius) var(--radius);
padding: 0.75rem 1.25rem;
}
.modal-title {
color: var(--text);
font-family: var(--font-display);
font-weight: 600;
font-size: 1rem;
} }
.modal-title { color: var(--text); }
/* ============================================================ /* ============================================================
Alerts Alerts
============================================================ */ ============================================================ */
.alert-info { .alert-info {
background-color: rgba(56, 189, 248, 0.1); background-color: rgba(96, 200, 255, 0.08);
border-color: rgba(56, 189, 248, 0.3); border-color: rgba(96, 200, 255, 0.22);
color: var(--info); color: var(--info);
border-radius: var(--radius-sm);
} }
.alert-success { .alert-success {
background-color: rgba(34, 197, 94, 0.1); background-color: rgba(52, 211, 153, 0.08);
border-color: rgba(34, 197, 94, 0.3); border-color: rgba(52, 211, 153, 0.22);
color: var(--success); color: var(--success);
border-radius: var(--radius-sm);
} }
.alert-danger { .alert-danger {
background-color: rgba(239, 68, 68, 0.1); background-color: rgba(248, 113, 113, 0.08);
border-color: rgba(239, 68, 68, 0.3); border-color: rgba(248, 113, 113, 0.22);
color: var(--danger); color: var(--danger);
border-radius: var(--radius-sm);
} }
/* ============================================================ /* ============================================================
@@ -425,6 +641,7 @@ option { background-color: var(--bg-raised); color: var(--text); }
background-color: var(--bg-card); background-color: var(--bg-card);
border-color: var(--border); border-color: var(--border);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.875rem;
} }
.page-link:hover { .page-link:hover {
background-color: var(--bg-raised); background-color: var(--bg-raised);
@@ -443,22 +660,31 @@ option { background-color: var(--bg-raised); color: var(--text); }
} }
/* ============================================================ /* ============================================================
Text / utility overrides Text / Utility overrides
============================================================ */ ============================================================ */
.text-muted { color: var(--text-muted) !important; } .text-muted { color: var(--text-muted) !important; }
h1, h2, h3, h4, h5, h6 { color: var(--text); }
.border-bottom { border-color: var(--border) !important; } .border-bottom { border-color: var(--border) !important; }
.border-top { border-color: var(--border) !important; } .border-top { border-color: var(--border) !important; }
.border { border-color: var(--border) !important; } .border { border-color: var(--border) !important; }
hr { border-color: var(--border); } hr { border-color: var(--border); opacity: 1; }
small { color: var(--text-muted); } small { color: var(--text-muted); font-size: 0.8em; }
.font-monospace { color: var(--text); } .font-monospace { color: var(--text); }
/* Spinner on dark bg */
.spinner-border.text-secondary { color: var(--text-muted) !important; } .spinner-border.text-secondary { color: var(--text-muted) !important; }
/* List group */
.list-group-item {
background-color: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
.list-group-item:hover { background-color: var(--bg-raised); }
.list-group-flush .list-group-item {
border-left: none;
border-right: none;
}
/* ============================================================ /* ============================================================
Gallery page Gallery Page
============================================================ */ ============================================================ */
.gallery-card { .gallery-card {
position: relative; position: relative;
@@ -471,20 +697,20 @@ small { color: var(--text-muted); }
} }
.gallery-card:hover { .gallery-card:hover {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 20px var(--accent-glow); box-shadow: 0 0 28px var(--accent-glow);
} }
.gallery-card img { .gallery-card img {
width: 100%; width: 100%;
aspect-ratio: 1; aspect-ratio: 1;
object-fit: cover; object-fit: cover;
display: block; display: block;
transition: transform 0.2s; transition: transform 0.25s ease;
} }
.gallery-card:hover img { transform: scale(1.04); } .gallery-card:hover img { transform: scale(1.04); }
.gallery-card .overlay { .gallery-card .overlay {
position: absolute; bottom: 0; left: 0; right: 0; position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.88)); background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
padding: 28px 8px 8px; padding: 32px 10px 10px;
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
@@ -496,16 +722,16 @@ small { color: var(--text-muted); }
.gallery-grid { .gallery-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px; gap: 8px;
} }
@media (min-width: 768px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } } @media (min-width: 768px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); } }
@media (min-width: 1200px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } } @media (min-width: 1200px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); } }
/* Lightbox */ /* Lightbox */
#lightbox { #lightbox {
display: none; position: fixed; inset: 0; display: none; position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.94); z-index: 9999; background: rgba(0, 0, 0, 0.97); z-index: 9999;
align-items: center; justify-content: center; flex-direction: column; align-items: center; justify-content: center; flex-direction: column;
} }
#lightbox.active { display: flex; } #lightbox.active { display: flex; }
@@ -513,16 +739,16 @@ small { color: var(--text-muted); }
#lightbox-img { #lightbox-img {
max-width: 90vw; max-height: 80vh; object-fit: contain; max-width: 90vw; max-height: 80vh; object-fit: contain;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 60px rgba(108, 99, 255, 0.3); box-shadow: 0 0 80px rgba(139, 126, 255, 0.22);
cursor: zoom-in; display: block; cursor: zoom-in; display: block;
} }
#lightbox-meta { color: #eee; margin-top: 10px; text-align: center; font-size: .85rem; } #lightbox-meta { color: #eee; margin-top: 12px; text-align: center; font-size: .85rem; }
#lightbox-hint { color: rgba(255,255,255,.38); font-size: .75rem; margin-top: 3px; } #lightbox-hint { color: rgba(255,255,255,.3); font-size: .75rem; margin-top: 4px; }
#lightbox-close { position: fixed; top: 16px; right: 20px; font-size: 2rem; color: #fff; cursor: pointer; z-index: 10000; line-height: 1; } #lightbox-close { position: fixed; top: 16px; right: 20px; font-size: 2rem; color: #fff; cursor: pointer; z-index: 10000; line-height: 1; }
#lightbox-actions { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; gap: 8px; } #lightbox-actions { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; gap: 8px; }
/* Prompt modal metadata */ /* Prompt modal metadata */
.meta-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: .85rem; } .meta-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: .85rem; }
.meta-grid .meta-label { color: var(--text-muted); white-space: nowrap; font-weight: 600; } .meta-grid .meta-label { color: var(--text-muted); white-space: nowrap; font-weight: 600; }
.meta-grid .meta-value { font-family: monospace; word-break: break-all; color: var(--text); } .meta-grid .meta-value { font-family: monospace; word-break: break-all; color: var(--text); }
@@ -534,49 +760,10 @@ small { color: var(--text-muted); }
font-size: .8rem; font-family: monospace; margin: 2px; font-size: .8rem; font-family: monospace; margin: 2px;
color: var(--text); color: var(--text);
} }
.lora-chip .lora-strength { color: var(--accent); } .lora-chip .lora-strength { color: var(--accent-bright); }
/* ============================================================ /* ============================================================
Misc Service Status Indicators (navbar)
============================================================ */
/* Vertical rule in navbar */
.vr { opacity: 1; background-color: var(--border); }
/* Inline icons inside buttons */
.btn img { height: 1em; vertical-align: -0.125em; }
/* Icon-only square button */
.btn-icon {
width: 34px;
height: 34px;
padding: 0;
display: inline-flex !important;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-icon img {
width: 18px;
height: 18px;
filter: brightness(0) invert(1);
}
/* Make forms invisible to flex layout so buttons flow naturally */
.d-contents { display: contents !important; }
/* Tags display */
.badge.rounded-pill { font-weight: 400; }
/* Textarea read-only */
textarea[readonly] {
background-color: var(--bg-raised) !important;
color: var(--text) !important;
border-color: var(--border) !important;
resize: vertical;
}
/* ============================================================
Service status indicators (navbar)
============================================================ */ ============================================================ */
.service-status { .service-status {
display: inline-flex; display: inline-flex;
@@ -584,30 +771,63 @@ textarea[readonly] {
gap: 4px; gap: 4px;
cursor: default; cursor: default;
user-select: none; user-select: none;
opacity: 0.85; opacity: 0.8;
} }
.service-status:hover { opacity: 1; } .service-status:hover { opacity: 1; }
.status-dot { .status-dot {
display: inline-block; display: inline-block;
width: 8px; width: 7px;
height: 8px; height: 7px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
transition: background-color 0.4s ease; transition: background-color 0.4s ease;
} }
.status-dot.status-ok { background-color: #3dd68c; box-shadow: 0 0 4px #3dd68c88; } .status-dot.status-ok { background-color: #34d399; box-shadow: 0 0 5px rgba(52, 211, 153, 0.55); }
.status-dot.status-error { background-color: #f06080; box-shadow: 0 0 4px #f0608088; } .status-dot.status-error { background-color: #f87171; box-shadow: 0 0 5px rgba(248, 113, 113, 0.55); }
.status-dot.status-checking { background-color: #888; animation: status-pulse 1.2s ease-in-out infinite; } .status-dot.status-checking { background-color: var(--border-light); animation: status-pulse 1.4s ease-in-out infinite; }
.status-label { .status-label {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 500; font-weight: 500;
color: rgba(255,255,255,0.65); color: rgba(255, 255, 255, 0.45);
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
@keyframes status-pulse { @keyframes status-pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.3; } 50% { opacity: 0.2; }
}
/* ============================================================
Misc
============================================================ */
.vr { opacity: 1; background-color: var(--border-light); }
.btn img { height: 1em; vertical-align: -0.125em; }
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex !important;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-icon img {
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
}
.d-contents { display: contents !important; }
.badge.rounded-pill { font-weight: 400; }
textarea[readonly] {
background-color: var(--bg-raised) !important;
color: var(--text) !important;
border-color: var(--border) !important;
resize: vertical;
} }

View File

@@ -56,7 +56,7 @@
<div class="card mb-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').src)">
{% if action.image_path %} {% 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 %} {% else %}
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
{% endif %} {% endif %}
@@ -97,35 +97,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center"> <span>Selected Preview</span>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)"> <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" 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> </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 mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -163,7 +148,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-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>
</div> </div>
@@ -288,7 +273,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -314,200 +300,75 @@
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context'); const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => { charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') { charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
}); });
// Generate a unique client ID function selectPreview(relativePath, imageUrl) {
const clientId = 'action_detail_' + Math.random().toString(36).substring(2, 15); if (!relativePath) return;
previewImg.src = imageUrl;
// ComfyUI WebSocket previewPath.value = relativePath;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
const nodeNames = { previewHeader.classList.replace('bg-secondary', 'bg-success');
"3": "Sampling", previewCard.classList.replace('border-secondary', 'border-success');
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding Image",
"9": "Saving Image"
};
let currentPromptId = null;
let currentAction = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (!currentPromptId) {
const queueRemaining = msg.data.status.exec_info.queue_remaining;
if (queueRemaining > 0) {
progressLabel.textContent = `Queue position: ${queueRemaining}`;
} }
}
}
else if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node; document.addEventListener('click', e => {
if (nodeId === null) { const img = e.target.closest('img[data-preview-path]');
// Execution finished via WebSocket if (img) selectPreview(img.dataset.previewPath, img.src);
console.log('Finished via WebSocket');
if (resolveCompletion) resolveCompletion();
} else {
const nodeName = nodeNames[nodeId] || `Processing...`;
progressLabel.textContent = nodeName;
}
}
}); });
let resolveCompletion = null; async function waitForJob(jobId) {
async function waitForCompletion(promptId) { return new Promise((resolve, reject) => {
return new Promise((resolve) => { const poll = setInterval(async () => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveCompletion = checkResolve;
// Fallback polling in case WebSocket is blocked (403)
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
console.log('Finished via Polling'); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
checkResolve(); else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} } catch (err) { console.error('Poll error:', err); }
} catch (err) { console.error('Polling error:', err); } }, 1500);
}, 2000);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') { if (!submitter || submitter.value !== 'preview') return;
return;
}
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
formData.append('client_id', clientId);
// UI Reset
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentPromptId = data.prompt_id;
progressLabel.textContent = 'Queued...';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
// Wait for completion (WebSocket or Polling)
await waitForCompletion(currentPromptId);
// Finalize
finalizeGeneration(currentPromptId, currentAction);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
});
async function finalizeGeneration(promptId, action) {
progressLabel.textContent = 'Saving image...';
const url = `/action/{{ action.slug }}/finalize_generation/${promptId}`;
const formData = new FormData();
formData.append('action', 'preview'); // Always save as preview
try {
const response = await fetch(url, {
method: 'POST',
body: formData
}); });
const data = await response.json(); const data = await response.json();
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'); }
});
if (data.success) {
// Update preview image
previewImg.src = data.image_url;
if (previewCard) previewCard.classList.remove('d-none');
// Enable the replace cover button if it exists
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) {
replaceBtn.disabled = false;
// Check if there's a form to update
const form = replaceBtn.closest('form');
if (form) {
form.action = `/action/{{ action.slug }}/replace_cover_from_preview`;
}
}
} else {
alert('Save failed: ' + data.error);
}
} catch (err) {
console.error(err);
alert('Finalize request failed');
} finally {
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/action/{{ action.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -515,7 +376,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -526,8 +387,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -543,52 +405,35 @@
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const 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 fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
currentPromptId = data.prompt_id; batchBar.style.width = '0%';
await waitForCompletion(currentPromptId); let done = 0;
const total = pending.length;
progressLabel.textContent = 'Saving image...'; batchLabel.textContent = `0 / ${total} complete`;
const finalFD = new FormData(); await Promise.all(pending.map(({ char, jobId }) =>
finalFD.append('action', 'preview'); waitForJob(jobId).then(result => {
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); done++;
const finalData = await finalResp.json(); batchBar.style.width = `${Math.round((done / total) * 100)}%`;
if (finalData.success) { batchLabel.textContent = `${done} / ${total} complete`;
addToPreviewGallery(finalData.image_url, char.name); if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
previewImg.src = finalData.image_url; }).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
if (previewCard) previewCard.classList.remove('d-none'); ));
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
@@ -600,10 +445,9 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); 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) }}'); initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
}); });

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="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>
@@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for action in actions %} {% for action in actions %}
<div class="col" id="card-{{ action.slug }}"> <div class="col" id="card-{{ action.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/action/{{ action.slug }}'"> <div class="card h-100 character-card" onclick="window.location.href='/action/{{ action.slug }}'">
@@ -102,71 +102,18 @@
const itemNameText = document.getElementById('current-item-name'); const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'actions_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
const nodeNames = {
"3": "Sampling",
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding",
"9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || `Processing...`;
stepProgressText.textContent = "";
if (nodeId !== "3") {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
} else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -184,67 +131,67 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const item of missing) { for (const item of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating Actions: ${completed + 1} / ${missing.length}`;
itemNameText.textContent = `Current: ${item.name}`;
nodeStatus.textContent = "Queuing...";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try { try {
// Random character for action preview
const genResp = await fetch(`/action/${item.slug}/generate`, { const genResp = await fetch(`/action/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
await waitForCompletion(currentPromptId); // Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
const finResp = await fetch(`/action/${item.slug}/finalize_generation/${currentPromptId}`, { let completed = 0;
method: 'POST', let currentItem = '';
body: new URLSearchParams({ 'action': 'replace' }) await Promise.all(jobs.map(async ({ item, jobId }) => {
}); currentItem = item.name;
const finData = await finResp.json(); itemNameText.textContent = `Processing: ${currentItem}`;
try {
if (finData.success) { const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
img.src = finData.image_url;
img.classList.remove('d-none');
}
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; completed++;
} const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Action Generation Complete!"; statusText.textContent = 'Batch Action Generation Complete!';
itemNameText.textContent = ""; itemNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = "";
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -47,7 +47,8 @@
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')"> onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
{% if ckpt.image_path %} {% 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 %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
@@ -89,39 +90,22 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2"> <small>Selected Preview</small>
<small>Latest Preview</small>
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.slug) }}" method="post" class="m-0">
<button type="submit" class="btn btn-sm btn-outline-light">Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center p-2">
<small>Latest Preview</small>
<form action="{{ url_for('replace_checkpoint_cover_from_preview', slug=ckpt.slug) }}" method="post" class="m-0" id="replace-cover-form"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img').src)"> onclick="showImage(this.querySelector('img') ? 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> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
@@ -131,7 +115,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-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>
</div> </div>
@@ -238,6 +222,7 @@
<img src="{{ url_for('static', filename='uploads/' + img) }}" <img src="{{ url_for('static', filename='uploads/' + img) }}"
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="{{ img }}"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"> data-bs-toggle="modal" data-bs-target="#imageModal">
</div> </div>
@@ -259,54 +244,38 @@
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img'); const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const clientId = 'checkpoint_detail_' + Math.random().toString(36).substring(2, 15); function selectPreview(relativePath, imageUrl) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); if (!relativePath) return;
previewImg.src = imageUrl;
const nodeNames = { previewPath.value = relativePath;
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", replaceBtn.disabled = false;
"4": "Loading Models", "16": "Character LoRA", "17": "Outfit LoRA", previewCard.classList.remove('d-none');
"18": "Action LoRA", "19": "Style/Detailer LoRA", "8": "Decoding Image", "9": "Saving Image" previewCardHeader.classList.replace('bg-secondary', 'bg-success');
}; previewCard.classList.replace('border-secondary', 'border-success');
let currentPromptId = null;
let resolveCompletion = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (!currentPromptId) {
const q = msg.data.status.exec_info.queue_remaining;
if (q > 0) progressLabel.textContent = `Queue position: ${q}`;
}
} else if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const percent = Math.round((msg.data.value / msg.data.max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
} else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveCompletion) resolveCompletion();
} else {
progressLabel.textContent = nodeNames[nodeId] || 'Processing...';
}
} }
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
}); });
async function waitForCompletion(promptId) { async function waitForJob(jobId) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const checkResolve = () => { clearInterval(pollInterval); resolve(); }; const poll = setInterval(async () => {
resolveCompletion = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') checkResolve(); 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…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -314,76 +283,29 @@
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', 'preview'); formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
alert('Error: ' + data.error); const jobResult = await waitForJob(data.job_id);
progressContainer.classList.add('d-none'); if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
return; } 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'); }
currentPromptId = data.prompt_id;
progressLabel.textContent = 'Queued...';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
await waitForCompletion(currentPromptId);
await finalizeGeneration(currentPromptId);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
}); });
async function finalizeGeneration(promptId) {
progressLabel.textContent = 'Saving image...';
const url = `/checkpoint/{{ ckpt.slug }}/finalize_generation/${promptId}`;
const formData = new FormData();
formData.append('action', 'preview');
try {
const response = await fetch(url, { method: 'POST', body: formData });
const data = await response.json();
if (data.success) {
previewImg.src = data.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
} else {
alert('Save failed: ' + data.error);
}
} catch (err) {
console.error(err);
alert('Finalize request failed');
} finally {
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters // Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/checkpoint/{{ ckpt.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
@@ -392,7 +314,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -401,6 +323,7 @@
col.innerHTML = `<div class="position-relative"> col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded" <img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
title="${charName}"> title="${charName}">
@@ -420,51 +343,34 @@
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); 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; 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(); const fd = new FormData();
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(form.getAttribute('action'), {
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
currentPromptId = data.prompt_id; // Phase 2: poll all in parallel
await waitForCompletion(currentPromptId); batchLabel.textContent = `0 / ${pending.length} complete`;
let done = 0;
progressLabel.textContent = 'Saving image...'; const total = pending.length;
const finalFD = new FormData(); await Promise.all(pending.map(({ char, jobId }) =>
finalFD.append('action', 'preview'); waitForJob(jobId).then(result => {
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); done++;
const finalData = await finalResp.json(); batchBar.style.width = `${Math.round((done / total) * 100)}%`;
if (finalData.success) { batchLabel.textContent = `${done} / ${total} complete`;
addToPreviewGallery(finalData.image_url, char.name); if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
previewImg.src = finalData.image_url; }).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
if (previewCard) previewCard.classList.remove('d-none'); ));
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
@@ -476,7 +382,7 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...'; batchLabel.textContent = 'Stopping after current submissions...';
}); });
// JSON Editor // JSON Editor

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="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>
@@ -44,7 +44,7 @@
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for ckpt in checkpoints %} {% for ckpt in checkpoints %}
<div class="col" id="card-{{ ckpt.slug }}"> <div class="col" id="card-{{ ckpt.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/checkpoint/{{ ckpt.slug }}'"> <div class="card h-100 character-card" onclick="window.location.href='/checkpoint/{{ ckpt.slug }}'">
@@ -84,55 +84,20 @@
const ckptNameText = document.getElementById('current-ckpt-name'); const ckptNameText = document.getElementById('current-ckpt-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'checkpoints_batch_' + Math.random().toString(36).substring(2, 15); let currentJobId = null;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
const nodeNames = { async function waitForJob(jobId) {
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", return new Promise((resolve, reject) => {
"4": "Loading Models", "16": "Character LoRA", "17": "Outfit LoRA", const poll = setInterval(async () => {
"18": "Action LoRA", "19": "Style/Detailer LoRA", "8": "Decoding", "9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const percent = Math.round((msg.data.value / msg.data.max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || 'Processing...';
stepProgressText.textContent = '';
if (nodeId !== '3') {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => { clearInterval(pollInterval); resolve(); };
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') checkResolve(); 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') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -150,50 +115,60 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
for (const ckpt of missing) { progressBar.style.width = '100%';
const percent = Math.round((completed / missing.length) * 100); progressBar.textContent = '';
progressBar.style.width = `${percent}%`; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressBar.textContent = `${percent}%`; nodeStatus.textContent = 'Queuing…';
statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`;
ckptNameText.textContent = `Current: ${ckpt.name}`;
nodeStatus.textContent = 'Queuing...';
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
const jobs = [];
for (const ckpt of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try { try {
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, { const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ 'client_id': clientId, 'character_slug': '__random__' }), body: new URLSearchParams({ character_slug: '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item: ckpt, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${ckpt.name}:`, err);
}
}
await waitForCompletion(currentPromptId); // Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
const finResp = await fetch(`/checkpoint/${ckpt.slug}/finalize_generation/${currentPromptId}`, { let completed = 0;
method: 'POST', let currentItem = '';
body: new URLSearchParams({ 'action': 'replace' }) await Promise.all(jobs.map(async ({ item, jobId }) => {
}); currentItem = item.name;
const finData = await finResp.json(); ckptNameText.textContent = `Processing: ${currentItem}`;
try {
if (finData.success) { const jobResult = await waitForJob(jobId);
const img = document.getElementById(`img-${ckpt.slug}`); if (jobResult.result && jobResult.result.image_url) {
const noImgSpan = document.getElementById(`no-img-${ckpt.slug}`); const img = document.getElementById(`img-${item.slug}`);
if (img) { img.src = finData.image_url; img.classList.remove('d-none'); } const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${ckpt.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; completed++;
} const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = 'Batch Generation Complete!'; statusText.textContent = 'Batch Checkpoint Generation Complete!';
ckptNameText.textContent = ''; ckptNameText.textContent = '';
nodeStatus.textContent = 'Done'; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ''; stepProgressText.textContent = '';
@@ -201,7 +176,7 @@
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -17,7 +17,7 @@
<div class="card mb-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').src)">
{% if character.image_path %} {% 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 %} {% else %}
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
{% endif %} {% endif %}
@@ -44,35 +44,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center"> <span>Selected Preview</span>
<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>
<form action="{{ url_for('replace_cover_from_preview', slug=character.slug) }}" method="post" class="m-0" id="replace-cover-form"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)"> <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" 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> </div>
</div> </div>
{% endif %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <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> <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> <a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
</div> </div>
<a href="/" class="btn btn-outline-secondary">Back to Gallery</a> <a href="/" class="btn btn-outline-secondary">Back to Library</a>
</div> </div>
<!-- Outfit Switcher --> <!-- Outfit Switcher -->
@@ -227,184 +212,90 @@
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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');
// Generate a unique client ID let currentJobId = null;
const clientId = 'detail_view_' + Math.random().toString(36).substring(2, 15);
// ComfyUI WebSocket function selectPreview(relativePath, imageUrl) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); if (!relativePath) return;
previewImg.src = imageUrl;
const nodeNames = { previewPath.value = relativePath;
"3": "Sampling", replaceBtn.disabled = false;
"11": "Face Detailing", previewCard.classList.remove('d-none');
"13": "Hand Detailing", previewHeader.classList.replace('bg-secondary', 'bg-success');
"4": "Loading Models", previewCard.classList.replace('border-secondary', 'border-success');
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding Image",
"9": "Saving Image"
};
let currentPromptId = null;
let currentAction = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (!currentPromptId) {
const queueRemaining = msg.data.status.exec_info.queue_remaining;
if (queueRemaining > 0) {
progressLabel.textContent = `Queue position: ${queueRemaining}`;
} }
}
}
else if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node; // Clicking any image with data-preview-path selects it into the preview pane
if (nodeId === null) { document.addEventListener('click', e => {
// Execution finished via WebSocket const img = e.target.closest('img[data-preview-path]');
console.log('Finished via WebSocket'); if (img) selectPreview(img.dataset.previewPath, img.src);
if (resolveCompletion) resolveCompletion();
} else {
const nodeName = nodeNames[nodeId] || `Processing...`;
progressLabel.textContent = nodeName;
}
}
}); });
let resolveCompletion = null; async function waitForJob(jobId) {
async function waitForCompletion(promptId) { return new Promise((resolve, reject) => {
return new Promise((resolve) => { const poll = setInterval(async () => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveCompletion = checkResolve;
// Fallback polling in case WebSocket is blocked (403)
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') {
console.log('Finished via Polling'); clearInterval(poll);
checkResolve(); 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…';
} }
} catch (err) { console.error('Polling error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 2000); }, 1500);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') { if (!submitter || submitter.value !== 'preview') return;
return;
}
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
formData.append('client_id', clientId);
// UI Reset
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%';
progressBar.textContent = '0%'; progressBar.textContent = '';
progressLabel.textContent = 'Starting...'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData,
body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentPromptId = data.prompt_id;
progressLabel.textContent = 'Queued...';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
// Wait for completion (WebSocket or Polling)
await waitForCompletion(currentPromptId);
// Finalize
finalizeGeneration(currentPromptId, currentAction);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
});
async function finalizeGeneration(promptId, action) {
progressLabel.textContent = 'Saving image...';
const url = `/character/{{ character.slug }}/finalize_generation/${promptId}`;
const formData = new FormData();
formData.append('action', 'preview'); // Always save as preview
try {
const response = await fetch(url, {
method: 'POST',
body: formData
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
if (data.success) { currentJobId = data.job_id;
// Update preview image progressLabel.textContent = 'Queued…';
previewImg.src = data.image_url; const jobResult = await waitForJob(currentJobId);
if (previewCard) previewCard.classList.remove('d-none'); currentJobId = null;
// Enable the replace cover button if it exists if (jobResult.result?.image_url) {
const replaceBtn = document.getElementById('replace-cover-btn'); selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
if (replaceBtn) {
replaceBtn.disabled = false;
// Check if there's a form to update
const form = replaceBtn.closest('form');
if (form) {
form.action = `/character/{{ character.slug }}/replace_cover_from_preview`;
}
}
} else {
alert('Save failed: ' + data.error);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Finalize request failed'); alert('Generation failed: ' + err.message);
} finally { } finally {
progressContainer.classList.add('d-none'); progressContainer.classList.add('d-none');
} progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
}); });
});
// Image modal function
function showImage(src) { function showImage(src) {
document.getElementById('modalImage').src = src; document.getElementById('modalImage').src = src;
} }

View File

@@ -56,7 +56,7 @@
<div class="card mb-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').src)">
{% if detailer.image_path %} {% 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 %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
@@ -121,35 +121,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2"> <small>Selected Preview</small>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)"> <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" 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> </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>
<div class="col-md-8"> <div class="col-md-8">
@@ -162,7 +147,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-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>
</div> </div>
@@ -257,7 +242,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -283,185 +269,76 @@
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context'); const charContext = document.getElementById('character-context');
const actionSelect = document.getElementById('action_select'); const actionSelect = document.getElementById('action_select');
// Toggle character context info
charSelect.addEventListener('change', () => { charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') { charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
}); });
// Generate a unique client ID function selectPreview(relativePath, imageUrl) {
const clientId = 'detailer_detail_' + Math.random().toString(36).substring(2, 15); if (!relativePath) return;
previewImg.src = imageUrl;
// ComfyUI WebSocket previewPath.value = relativePath;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
const nodeNames = { previewHeader.classList.replace('bg-secondary', 'bg-success');
"3": "Sampling", previewCard.classList.replace('border-secondary', 'border-success');
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding Image",
"9": "Saving Image"
};
let currentPromptId = null;
let currentAction = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (!currentPromptId) {
const queueRemaining = msg.data.status.exec_info.queue_remaining;
if (queueRemaining > 0) {
progressLabel.textContent = `Queue position: ${queueRemaining}`;
} }
}
}
else if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node; document.addEventListener('click', e => {
if (nodeId === null) { const img = e.target.closest('img[data-preview-path]');
// Execution finished via WebSocket if (img) selectPreview(img.dataset.previewPath, img.src);
console.log('Finished via WebSocket');
if (resolveCompletion) resolveCompletion();
} else {
const nodeName = nodeNames[nodeId] || `Processing...`;
progressLabel.textContent = nodeName;
}
}
}); });
let resolveCompletion = null; async function waitForJob(jobId) {
async function waitForCompletion(promptId) { return new Promise((resolve, reject) => {
return new Promise((resolve) => { const poll = setInterval(async () => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveCompletion = checkResolve;
// Fallback polling in case WebSocket is blocked (403)
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
console.log('Finished via Polling'); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
checkResolve(); else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} } catch (err) { console.error('Poll error:', err); }
} catch (err) { console.error('Polling error:', err); } }, 1500);
}, 2000);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData, });
headers: { 'X-Requested-With': 'XMLHttpRequest' } const data = await response.json();
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'); }
}); });
const data = await response.json();
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentPromptId = data.prompt_id;
progressLabel.textContent = 'Queued...';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
await waitForCompletion(currentPromptId);
finalizeGeneration(currentPromptId, currentAction);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
});
async function finalizeGeneration(promptId, action) {
progressLabel.textContent = 'Saving image...';
const url = `/detailer/{{ detailer.slug }}/finalize_generation/${promptId}`;
const formData = new FormData();
formData.append('action', 'preview'); // Always save as preview
try {
const response = await fetch(url, { method: 'POST', body: formData });
const data = await response.json();
if (data.success) {
previewImg.src = data.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) {
replaceBtn.disabled = false;
const form = replaceBtn.closest('form');
if (form) {
form.action = `/detailer/{{ detailer.slug }}/replace_cover_from_preview`;
}
}
} else {
alert('Save failed: ' + data.error);
}
} catch (err) {
console.error(err);
alert('Finalize request failed');
} finally {
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/detailer/{{ detailer.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -469,7 +346,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -480,8 +357,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -497,13 +375,12 @@
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const 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 fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
@@ -511,41 +388,25 @@
fd.append('extra_positive', document.getElementById('extra_positive').value); fd.append('extra_positive', document.getElementById('extra_positive').value);
fd.append('extra_negative', document.getElementById('extra_negative').value); fd.append('extra_negative', document.getElementById('extra_negative').value);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
currentPromptId = data.prompt_id; batchBar.style.width = '0%';
await waitForCompletion(currentPromptId); let done = 0;
const total = pending.length;
progressLabel.textContent = 'Saving image...'; batchLabel.textContent = `0 / ${total} complete`;
const finalFD = new FormData(); await Promise.all(pending.map(({ char, jobId }) =>
finalFD.append('action', 'preview'); waitForJob(jobId).then(result => {
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); done++;
const finalData = await finalResp.json(); batchBar.style.width = `${Math.round((done / total) * 100)}%`;
if (finalData.success) { batchLabel.textContent = `${done} / ${total} complete`;
addToPreviewGallery(finalData.image_url, char.name); if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
previewImg.src = finalData.image_url; }).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
if (previewCard) previewCard.classList.remove('d-none'); ));
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
@@ -557,10 +418,9 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); 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) }}'); initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
}); });

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="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>
@@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for detailer in detailers %} {% for detailer in detailers %}
<div class="col" id="card-{{ detailer.slug }}"> <div class="col" id="card-{{ detailer.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/detailer/{{ detailer.slug }}'"> <div class="card h-100 character-card" onclick="window.location.href='/detailer/{{ detailer.slug }}'">
@@ -104,72 +104,18 @@
const detailerNameText = document.getElementById('current-detailer-name'); const detailerNameText = document.getElementById('current-detailer-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'detailers_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
const nodeNames = {
"3": "Sampling",
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding",
"9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || `Processing...`;
stepProgressText.textContent = "";
if (nodeId !== "3") {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
} else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -187,66 +133,68 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const item of missing) { for (const item of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating Detailers: ${completed + 1} / ${missing.length}`;
detailerNameText.textContent = `Current: ${item.name}`;
nodeStatus.textContent = "Queuing...";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try { try {
const genResp = await fetch(`/detailer/${item.slug}/generate`, { const genResp = await fetch(`/detailer/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
await waitForCompletion(currentPromptId); // Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
const finResp = await fetch(`/detailer/${item.slug}/finalize_generation/${currentPromptId}`, { let completed = 0;
method: 'POST', let currentItem = '';
body: new URLSearchParams({ 'action': 'replace' }) await Promise.all(jobs.map(async ({ item, jobId }) => {
}); currentItem = item.name;
const finData = await finResp.json(); detailerNameText.textContent = `Processing: ${currentItem}`;
try {
if (finData.success) { const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
img.src = finData.image_url;
img.classList.remove('d-none');
}
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; completed++;
} const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Detailer Generation Complete!"; statusText.textContent = 'Batch Detailer Generation Complete!';
detailerNameText.textContent = ""; detailerNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

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

View File

@@ -310,73 +310,24 @@
const placeholder = document.getElementById('placeholder-text'); const placeholder = document.getElementById('placeholder-text');
const resultFooter = document.getElementById('result-footer'); const resultFooter = document.getElementById('result-footer');
const clientId = 'generator_view_' + Math.random().toString(36).substring(2, 15); let currentJobId = null;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId);
const nodeNames = {
"3": "Sampling", "4": "Loading Models", "8": "Decoding Image", "9": "Saving Image",
"11": "Face Detailing", "13": "Hand Detailing",
"16": "Character LoRA", "17": "Outfit LoRA", "18": "Action LoRA", "19": "Style/Detailer LoRA"
};
let currentPromptId = null;
let resolveCompletion = null;
let stopRequested = false; let stopRequested = false;
socket.addEventListener('message', (event) => { async function waitForJob(jobId) {
const msg = JSON.parse(event.data); return new Promise((resolve, reject) => {
if (msg.type === 'status') {
if (!currentPromptId) {
const q = msg.data.status.exec_info.queue_remaining;
if (q > 0) progressLbl.textContent = `Queue position: ${q}`;
}
} else if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const pct = Math.round((msg.data.value / msg.data.max) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
} else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
if (msg.data.node === null) {
if (resolveCompletion) resolveCompletion();
} else {
progressLbl.textContent = nodeNames[msg.data.node] || 'Processing...';
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const done = () => { clearInterval(poll); resolve(); };
resolveCompletion = done;
const poll = setInterval(async () => { const poll = setInterval(async () => {
try { try {
const r = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
if ((await r.json()).status === 'finished') done(); const data = await resp.json();
} catch (_) {} if (data.status === 'done') { clearInterval(poll); resolve(data); }
}, 2000); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLbl.textContent = 'Generating…';
else progressLbl.textContent = 'Queued…';
} catch (err) {}
}, 1500);
}); });
} }
async function finalizeGeneration(slug, promptId) {
progressLbl.textContent = 'Saving image...';
try {
const r = await fetch(`/generator/finalize/${slug}/${promptId}`, { method: 'POST' });
const data = await r.json();
if (data.success) {
resultImg.src = data.image_url;
resultImg.parentElement.classList.remove('d-none');
if (placeholder) placeholder.classList.add('d-none');
resultFooter.classList.remove('d-none');
} else {
alert('Save failed: ' + data.error);
}
} catch (err) {
console.error(err);
alert('Finalize request failed');
}
}
function setGeneratingState(active) { function setGeneratingState(active) {
generateBtn.disabled = active; generateBtn.disabled = active;
endlessBtn.disabled = active; endlessBtn.disabled = active;
@@ -388,12 +339,11 @@
if (document.getElementById('lucky-dip').checked) applyLuckyDip(); if (document.getElementById('lucky-dip').checked) applyLuckyDip();
progressCont.classList.remove('d-none'); progressCont.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLbl.textContent = label; progressLbl.textContent = label;
const fd = new FormData(form); const fd = new FormData(form);
fd.append('client_id', clientId);
const resp = await fetch(form.action, { const resp = await fetch(form.action, {
method: 'POST', body: fd, method: 'POST', body: fd,
@@ -402,15 +352,19 @@
const data = await resp.json(); const data = await resp.json();
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
currentPromptId = data.prompt_id; currentJobId = data.job_id;
progressLbl.textContent = 'Queued...'; progressLbl.textContent = 'Queued';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
await waitForCompletion(currentPromptId); const jobResult = await waitForJob(currentJobId);
await finalizeGeneration(document.getElementById('character').value, currentPromptId); currentJobId = null;
currentPromptId = null;
if (jobResult.result && jobResult.result.image_url) {
resultImg.src = jobResult.result.image_url;
resultImg.parentElement.classList.remove('d-none');
if (placeholder) placeholder.classList.add('d-none');
resultFooter.classList.remove('d-none');
}
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
async function runLoop(endless) { async function runLoop(endless) {

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="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>
@@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for char in characters %} {% for char in characters %}
<div class="col" id="card-{{ char.slug }}"> <div class="col" id="card-{{ char.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/character/{{ char.slug }}'"> <div class="card h-100 character-card" onclick="window.location.href='/character/{{ char.slug }}'">
@@ -103,72 +103,18 @@
const charNameText = document.getElementById('current-char-name'); const charNameText = document.getElementById('current-char-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'gallery_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
const nodeNames = {
"3": "Sampling",
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding",
"9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || `Processing...`;
stepProgressText.textContent = "";
// Reset task bar for new node if it's not sampling
if (nodeId !== "3") {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
} else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -186,62 +132,68 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront so the page can be navigated away from
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const char of missing) { for (const char of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`;
charNameText.textContent = `Current: ${char.name}`;
nodeStatus.textContent = "Queuing...";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try { try {
const genResp = await fetch(`/character/${char.slug}/generate`, { const genResp = await fetch(`/character/${char.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ 'action': 'replace', 'client_id': clientId }), body: new URLSearchParams({ action: 'replace' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item: char, jobId: genData.job_id });
} catch (err) {
await waitForCompletion(currentPromptId); console.error(`Failed to queue ${char.name}:`, err);
const finResp = await fetch(`/character/${char.slug}/finalize_generation/${currentPromptId}`, {
method: 'POST',
body: new URLSearchParams({ 'action': 'replace' })
});
const finData = await finResp.json();
if (finData.success) {
const img = document.getElementById(`img-${char.slug}`);
const noImgSpan = document.getElementById(`no-img-${char.slug}`);
if (img) {
img.src = finData.image_url;
img.classList.remove('d-none');
} }
}
// Phase 2: Poll all jobs concurrently; update UI as each finishes
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
charNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${char.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; completed++;
} const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Complete!"; statusText.textContent = 'Batch Complete!';
charNameText.textContent = ""; charNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -6,7 +6,7 @@
<title>GAZE</title> <title>GAZE</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
</head> </head>
@@ -23,12 +23,19 @@
<a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a> <a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a>
<a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a> <a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a>
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a> <a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
<div class="vr mx-1 d-none d-lg-block"></div> <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="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</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> <a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div> <div class="vr mx-1 d-none d-lg-block"></div>
<!-- Queue indicator -->
<button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Generation Queue">
<span class="queue-icon"></span>
<span id="queue-count-badge" class="queue-badge d-none">0</span>
</button>
<div class="vr mx-1 d-none d-lg-block"></div>
<!-- Service status indicators --> <!-- Service status indicators -->
<span id="status-comfyui" class="service-status" title="ComfyUI" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="ComfyUI: checking…"> <span id="status-comfyui" class="service-status" title="ComfyUI" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="ComfyUI: checking…">
<span class="status-dot status-checking"></span> <span class="status-dot status-checking"></span>
@@ -38,23 +45,14 @@
<span class="status-dot status-checking"></span> <span class="status-dot status-checking"></span>
<span class="status-label d-none d-xl-inline">MCP</span> <span class="status-label d-none d-xl-inline">MCP</span>
</span> </span>
<span id="status-llm" class="service-status" title="LLM" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="LLM: checking…">
<span class="status-dot status-checking"></span>
<span class="status-label d-none d-xl-inline">LLM</span>
</span>
</div> </div>
</div> </div>
</nav> </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"> <div class="container">
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
{% if messages %} {% if messages %}
@@ -94,26 +92,73 @@
</div> </div>
</div> </div>
<!-- Generation Queue Modal -->
<div class="modal fade" id="queueModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Generation Queue
<span id="queue-modal-count" class="badge bg-secondary ms-2">0</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="queue-empty-msg" class="text-center text-muted py-4">
<p class="mb-0">No jobs in queue.</p>
</div>
<ul id="queue-job-list" class="list-group list-group-flush d-none"></ul>
</div>
<div class="modal-footer">
<small class="text-muted me-auto">Jobs are processed sequentially. Close this window to continue browsing.</small>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el)); document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
});
const ckptSelect = document.getElementById('defaultCheckpointSelect'); // ---- Loaded checkpoint → ComfyUI tooltip ----
const saveStatus = document.getElementById('checkpointSaveStatus'); (function() {
if (ckptSelect) { let _loadedCheckpoint = null;
ckptSelect.addEventListener('change', () => { async function pollLoadedCheckpoint() {
fetch('/set_default_checkpoint', { try {
method: 'POST', const r = await fetch('/api/comfyui/loaded_checkpoint', { cache: 'no-store' });
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, const data = await r.json();
body: 'checkpoint_path=' + encodeURIComponent(ckptSelect.value) _loadedCheckpoint = data.checkpoint || null;
}).then(() => { } catch {
saveStatus.style.opacity = '1'; _loadedCheckpoint = null;
setTimeout(() => { saveStatus.style.opacity = '0'; }, 1500);
});
});
} }
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>
<script> <script>
// ---- Resource delete modal (category galleries) ---- // ---- Resource delete modal (category galleries) ----
@@ -302,6 +347,7 @@
const services = [ const services = [
{ id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' }, { id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' },
{ id: 'status-mcp', url: '/api/status/mcp', label: 'Danbooru MCP' }, { id: 'status-mcp', url: '/api/status/mcp', label: 'Danbooru MCP' },
{ id: 'status-llm', url: '/api/status/llm', label: 'LLM' },
]; ];
function setStatus(id, label, ok) { function setStatus(id, label, ok) {
@@ -309,14 +355,15 @@
if (!el) return; if (!el) return;
const dot = el.querySelector('.status-dot'); const dot = el.querySelector('.status-dot');
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error'); dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
if (id === 'status-comfyui' && window._updateComfyTooltip) {
window._updateComfyTooltip();
return;
}
const tooltipText = label + ': ' + (ok ? 'online' : 'offline'); const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
el.setAttribute('data-bs-title', tooltipText); el.setAttribute('data-bs-title', tooltipText);
el.setAttribute('title', tooltipText); el.setAttribute('title', tooltipText);
// Refresh tooltip instance if already initialised
const tip = bootstrap.Tooltip.getInstance(el); const tip = bootstrap.Tooltip.getInstance(el);
if (tip) { if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
tip.setContent({ '.tooltip-inner': tooltipText });
}
} }
async function pollService(svc) { async function pollService(svc) {
@@ -339,6 +386,148 @@
}); });
})(); })();
</script> </script>
<script>
// ---- Generation Queue UI ----
(function() {
const badge = document.getElementById('queue-count-badge');
const modalCount = document.getElementById('queue-modal-count');
const jobList = document.getElementById('queue-job-list');
const emptyMsg = document.getElementById('queue-empty-msg');
const STATUS_LABELS = {
pending: { text: 'Pending', cls: 'text-muted' },
processing: { text: 'Generating…', cls: 'text-warning' },
paused: { text: 'Paused', cls: 'text-secondary' },
done: { text: 'Done', cls: 'text-success' },
failed: { text: 'Failed', cls: 'text-danger' },
removed: { text: 'Removed', cls: 'text-muted' },
};
function renderQueue(jobs) {
const activeJobs = jobs.filter(j => !['done', 'failed', 'removed'].includes(j.status));
const count = activeJobs.length;
// Update badge
if (count > 0) {
badge.textContent = count;
badge.classList.remove('d-none');
document.getElementById('queue-btn').classList.add('queue-btn-active');
} else {
badge.classList.add('d-none');
document.getElementById('queue-btn').classList.remove('queue-btn-active');
}
// Update modal count
if (modalCount) modalCount.textContent = jobs.length;
// Render job list
if (!jobList) return;
if (jobs.length === 0) {
jobList.classList.add('d-none');
if (emptyMsg) emptyMsg.classList.remove('d-none');
return;
}
jobList.classList.remove('d-none');
if (emptyMsg) emptyMsg.classList.add('d-none');
jobList.innerHTML = '';
jobs.forEach(job => {
const li = document.createElement('li');
li.className = 'list-group-item d-flex align-items-center gap-2 py-2';
li.id = `queue-job-${job.id}`;
const statusInfo = STATUS_LABELS[job.status] || { text: job.status, cls: 'text-muted' };
// Status indicator
const statusDot = document.createElement('span');
statusDot.className = `queue-status-dot queue-status-${job.status}`;
li.appendChild(statusDot);
// Label
const label = document.createElement('span');
label.className = 'flex-grow-1 small';
label.textContent = job.label;
li.appendChild(label);
// Status text
const statusText = document.createElement('span');
statusText.className = `small ${statusInfo.cls}`;
statusText.textContent = statusInfo.text;
if (job.status === 'failed' && job.error) {
statusText.title = job.error;
statusText.style.cursor = 'help';
}
li.appendChild(statusText);
// Action buttons
const btnGroup = document.createElement('div');
btnGroup.className = 'd-flex gap-1';
if (job.status === 'pending') {
const pauseBtn = document.createElement('button');
pauseBtn.className = 'btn btn-xs btn-outline-secondary';
pauseBtn.textContent = '⏸';
pauseBtn.title = 'Pause';
pauseBtn.onclick = () => queuePause(job.id);
btnGroup.appendChild(pauseBtn);
}
if (job.status === 'paused') {
const resumeBtn = document.createElement('button');
resumeBtn.className = 'btn btn-xs btn-outline-success';
resumeBtn.textContent = '▶';
resumeBtn.title = 'Resume';
resumeBtn.onclick = () => queuePause(job.id);
btnGroup.appendChild(resumeBtn);
}
if (['pending', 'paused', 'failed'].includes(job.status)) {
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-xs btn-outline-danger';
removeBtn.textContent = '✕';
removeBtn.title = 'Remove';
removeBtn.onclick = () => queueRemove(job.id);
btnGroup.appendChild(removeBtn);
}
li.appendChild(btnGroup);
jobList.appendChild(li);
});
}
async function fetchQueue() {
try {
const resp = await fetch('/api/queue');
const data = await resp.json();
renderQueue(data.jobs || []);
} catch (e) {}
}
async function queueRemove(jobId) {
try {
await fetch(`/api/queue/${jobId}/remove`, { method: 'POST' });
fetchQueue();
} catch (e) {}
}
async function queuePause(jobId) {
try {
await fetch(`/api/queue/${jobId}/pause`, { method: 'POST' });
fetchQueue();
} catch (e) {}
}
// Poll queue every 2 seconds
document.addEventListener('DOMContentLoaded', () => {
fetchQueue();
setInterval(fetchQueue, 2000);
// Refresh when modal opens
const queueModal = document.getElementById('queueModal');
if (queueModal) {
queueModal.addEventListener('show.bs.modal', fetchQueue);
}
});
})();
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -43,9 +43,10 @@
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<div class="card mb-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 %} {% 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 %} {% else %}
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
{% endif %} {% endif %}
@@ -89,35 +90,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center"> <span>Selected Preview</span>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)"> <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" 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) }}" 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> </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 mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -156,7 +142,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-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>
</div> </div>
@@ -237,47 +223,38 @@
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img'); const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const clientId = 'look_detail_' + Math.random().toString(36).substring(2, 15); function selectPreview(relativePath, imageUrl) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); if (!relativePath) return;
previewImg.src = imageUrl;
const nodeNames = { previewPath.value = relativePath;
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing", replaceBtn.disabled = false;
"4": "Loading Models", "16": "Look LoRA", "17": "Outfit LoRA", previewCard.classList.remove('d-none');
"18": "Action LoRA", "19": "Style/Detailer LoRA", previewCardHeader.classList.replace('bg-secondary', 'bg-success');
"8": "Decoding Image", "9": "Saving Image" previewCard.classList.replace('border-secondary', 'border-success');
};
let currentPromptId = null;
let resolveCompletion = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress' && msg.data.prompt_id === currentPromptId) {
const percent = Math.round((msg.data.value / msg.data.max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
} else if (msg.type === 'executing' && msg.data.prompt_id === currentPromptId) {
if (msg.data.node === null) {
if (resolveCompletion) resolveCompletion();
} else {
progressLabel.textContent = nodeNames[msg.data.node] || 'Processing...';
}
} }
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
}); });
async function waitForCompletion(promptId) { async function waitForJob(jobId) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const checkResolve = () => { clearInterval(pollInterval); resolve(); }; const poll = setInterval(async () => {
resolveCompletion = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') checkResolve(); if (data.status === 'done') { clearInterval(poll); resolve(data); }
} catch (err) { console.error('Polling error:', err); } else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
}, 2000); else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
}); });
} }
@@ -285,67 +262,27 @@
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', 'preview'); formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData, method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await response.json(); 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); progressContainer.classList.add('d-none'); return; }
const jobResult = await waitForJob(data.job_id);
currentPromptId = data.prompt_id; if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
progressLabel.textContent = 'Queued...'; } catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
progressBar.style.width = '100%'; finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
await waitForCompletion(currentPromptId);
finalizeGeneration(currentPromptId);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
}); });
async function finalizeGeneration(promptId) {
progressLabel.textContent = 'Saving image...';
const url = `/look/{{ look.slug }}/finalize_generation/${promptId}`;
try {
const response = await fetch(url, {
method: 'POST', body: new FormData()
});
const data = await response.json();
if (data.success) {
previewImg.src = data.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
} else {
alert('Save failed: ' + data.error);
}
} catch (err) {
console.error(err);
alert('Finalize request failed');
} finally {
progressContainer.classList.add('d-none');
}
}
}); });
function showImage(src) { function showImage(src) {
document.getElementById('modalImage').src = src; if (src) document.getElementById('modalImage').src = src;
} }
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}'); initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="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>
@@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for look in looks %} {% for look in looks %}
<div class="col" id="card-{{ look.slug }}"> <div class="col" id="card-{{ look.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='{{ url_for('look_detail', slug=look.slug) }}'"> <div class="card h-100 character-card" onclick="window.location.href='{{ url_for('look_detail', slug=look.slug) }}'">
@@ -59,6 +59,9 @@
<img id="img-{{ look.slug }}" src="" alt="{{ look.name }}" class="d-none"> <img id="img-{{ look.slug }}" src="" alt="{{ look.name }}" class="d-none">
<span id="no-img-{{ look.slug }}" class="text-muted">No Image</span> <span id="no-img-{{ look.slug }}" class="text-muted">No Image</span>
{% endif %} {% endif %}
{% if look_assignments.get(look.look_id, 0) > 0 %}
<span class="assignment-badge" title="Assigned to {{ look_assignments.get(look.look_id, 0) }} character(s)">{{ look_assignments.get(look.look_id, 0) }}</span>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ look.name }}</h5> <h5 class="card-title text-center">{{ look.name }}</h5>
@@ -105,56 +108,36 @@
const itemNameText = document.getElementById('current-item-name'); const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'looks_batch_' + Math.random().toString(36).substring(2, 15); let currentJobId = null;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); let queuePollInterval = null;
const nodeNames = { async function updateCurrentJobLabel() {
"3": "Sampling", "11": "Face Detailing", "13": "Hand Detailing",
"4": "Loading Models", "16": "Look LoRA", "17": "Outfit LoRA",
"18": "Action LoRA", "19": "Style/Detailer LoRA",
"8": "Decoding", "9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const percent = Math.round((msg.data.value / msg.data.max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || 'Processing...';
stepProgressText.textContent = "";
if (nodeId !== "3") {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => { clearInterval(pollInterval); resolve(); };
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch('/api/queue');
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') checkResolve(); const processingJob = data.jobs.find(j => j.status === 'processing');
if (processingJob) {
itemNameText.textContent = `Processing: ${processingJob.label}`;
} else {
itemNameText.textContent = '';
}
} catch (err) {
console.error('Failed to fetch queue:', err);
}
}
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
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') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -172,62 +155,75 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
for (const item of missing) { progressBar.style.width = '100%';
const percent = Math.round((completed / missing.length) * 100); progressBar.textContent = '';
progressBar.style.width = `${percent}%`; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressBar.textContent = `${percent}%`; nodeStatus.textContent = 'Queuing…';
statusText.textContent = `Batch Generating Looks: ${completed + 1} / ${missing.length}`;
itemNameText.textContent = `Current: ${item.name}`;
nodeStatus.textContent = "Queuing...";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
const jobs = [];
for (const item of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try { try {
// Looks are self-contained — no character_slug passed
const genResp = await fetch(`/look/${item.slug}/generate`, { const genResp = await fetch(`/look/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace' }),
'action': 'replace',
'client_id': clientId
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
await waitForCompletion(currentPromptId); // Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
const finResp = await fetch(`/look/${item.slug}/finalize_generation/${currentPromptId}`, { // Start polling queue for current job label
method: 'POST', queuePollInterval = setInterval(updateCurrentJobLabel, 1000);
body: new URLSearchParams({ 'action': 'replace' }) updateCurrentJobLabel(); // Initial update
});
const finData = await finResp.json();
if (finData.success) { let completed = 0;
await Promise.all(jobs.map(async ({ item, jobId }) => {
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = finData.image_url; img.classList.remove('d-none'); } if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; completed++;
const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
// Stop polling queue
if (queuePollInterval) {
clearInterval(queuePollInterval);
queuePollInterval = null;
} }
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Look Generation Complete!"; statusText.textContent = 'Batch Look Generation Complete!';
itemNameText.textContent = ""; itemNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -45,7 +45,7 @@
<div class="card mb-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').src)">
{% if outfit.image_path %} {% 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 %} {% else %}
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
{% endif %} {% endif %}
@@ -86,35 +86,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center"> <span>Selected Preview</span>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)"> <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" 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> </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 mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -152,7 +137,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-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>
</div> </div>
@@ -245,7 +230,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -271,188 +257,91 @@
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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');
// Generate a unique client ID function selectPreview(relativePath, imageUrl) {
const clientId = 'outfit_detail_' + Math.random().toString(36).substring(2, 15); if (!relativePath) return;
previewImg.src = imageUrl;
// ComfyUI WebSocket previewPath.value = relativePath;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
const nodeNames = { previewHeader.classList.replace('bg-secondary', 'bg-success');
"3": "Sampling", previewCard.classList.replace('border-secondary', 'border-success');
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding Image",
"9": "Saving Image"
};
let currentPromptId = null;
let currentAction = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (!currentPromptId) {
const queueRemaining = msg.data.status.exec_info.queue_remaining;
if (queueRemaining > 0) {
progressLabel.textContent = `Queue position: ${queueRemaining}`;
} }
}
}
else if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node; // Clicking any image with data-preview-path selects it into the preview pane
if (nodeId === null) { document.addEventListener('click', e => {
// Execution finished via WebSocket const img = e.target.closest('img[data-preview-path]');
console.log('Finished via WebSocket'); if (img) selectPreview(img.dataset.previewPath, img.src);
if (resolveCompletion) resolveCompletion();
} else {
const nodeName = nodeNames[nodeId] || `Processing...`;
progressLabel.textContent = nodeName;
}
}
}); });
let resolveCompletion = null; async function waitForJob(jobId) {
async function waitForCompletion(promptId) { return new Promise((resolve, reject) => {
return new Promise((resolve) => { const poll = setInterval(async () => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveCompletion = checkResolve;
// Fallback polling in case WebSocket is blocked (403)
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') {
console.log('Finished via Polling'); clearInterval(poll);
checkResolve(); 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…';
} }
} catch (err) { console.error('Polling error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 2000); }, 1500);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
// Only intercept generate actions
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') { if (!submitter || submitter.value !== 'preview') return;
return;
}
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
formData.append('client_id', clientId);
// UI Reset
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%';
progressBar.textContent = '0%'; progressBar.textContent = '';
progressLabel.textContent = 'Starting...'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData,
body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentPromptId = data.prompt_id;
progressLabel.textContent = 'Queued...';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
// Wait for completion (WebSocket or Polling)
await waitForCompletion(currentPromptId);
// Finalize
finalizeGeneration(currentPromptId, currentAction);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
});
async function finalizeGeneration(promptId, action) {
progressLabel.textContent = 'Saving image...';
const url = `/outfit/{{ outfit.slug }}/finalize_generation/${promptId}`;
const formData = new FormData();
formData.append('action', 'preview'); // Always save as preview
try {
const response = await fetch(url, {
method: 'POST',
body: formData
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
if (data.success) { progressLabel.textContent = 'Queued…';
// Update preview image const jobResult = await waitForJob(data.job_id);
previewImg.src = data.image_url;
if (previewCard) previewCard.classList.remove('d-none');
// Enable the replace cover button if it exists if (jobResult.result?.image_url) {
const replaceBtn = document.getElementById('replace-cover-btn'); selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
if (replaceBtn) { addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
replaceBtn.disabled = false;
// Check if there's a form to update
const form = replaceBtn.closest('form');
if (form) {
form.action = `/outfit/{{ outfit.slug }}/replace_cover_from_preview`;
}
}
} else {
alert('Save failed: ' + data.error);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Finalize request failed'); alert('Generation failed: ' + err.message);
} finally { } finally {
progressContainer.classList.add('d-none'); progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
} });
// Batch: Generate All Characters // Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/outfit/{{ outfit.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -460,7 +349,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -471,8 +360,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -487,47 +377,46 @@
stopAllBtn.classList.remove('d-none'); stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const 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 fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
currentPromptId = data.prompt_id; } catch (err) { console.error(`Submit error for ${char.name}:`, err); }
await waitForCompletion(currentPromptId);
progressLabel.textContent = 'Saving image...';
const finalFD = new FormData();
finalFD.append('action', 'preview');
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD });
const finalData = await finalResp.json();
if (finalData.success) {
addToPreviewGallery(finalData.image_url, char.name);
previewImg.src = finalData.image_url;
if (previewCard) previewCard.classList.remove('d-none');
} }
currentPromptId = null;
} catch (err) { // 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); console.error(`Failed for ${char.name}:`, err);
currentPromptId = null; })
} finally { ));
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false; generateAllBtn.disabled = false;
@@ -538,7 +427,7 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...'; batchLabel.textContent = 'Stopping';
}); });
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}'); initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="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>
@@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for outfit in outfits %} {% for outfit in outfits %}
<div class="col" id="card-{{ outfit.slug }}"> <div class="col" id="card-{{ outfit.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/outfit/{{ outfit.slug }}'"> <div class="card h-100 character-card" onclick="window.location.href='/outfit/{{ outfit.slug }}'">
@@ -59,6 +59,9 @@
<img id="img-{{ outfit.slug }}" src="" alt="{{ outfit.name }}" class="d-none"> <img id="img-{{ outfit.slug }}" src="" alt="{{ outfit.name }}" class="d-none">
<span id="no-img-{{ outfit.slug }}" class="text-muted">No Image</span> <span id="no-img-{{ outfit.slug }}" class="text-muted">No Image</span>
{% endif %} {% endif %}
{% if outfit.data.lora and outfit.data.lora.lora_name and lora_assignments.get(outfit.data.lora.lora_name, 0) > 0 %}
<span class="assignment-badge" title="Assigned to {{ lora_assignments.get(outfit.data.lora.lora_name, 0) }} character(s)">{{ lora_assignments.get(outfit.data.lora.lora_name, 0) }}</span>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ outfit.name }}</h5> <h5 class="card-title text-center">{{ outfit.name }}</h5>
@@ -102,71 +105,18 @@
const itemNameText = document.getElementById('current-item-name'); const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'outfits_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
const nodeNames = {
"3": "Sampling",
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding",
"9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || `Processing...`;
stepProgressText.textContent = "";
if (nodeId !== "3") {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
} else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -184,66 +134,68 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const item of missing) { for (const item of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating Outfits: ${completed + 1} / ${missing.length}`;
itemNameText.textContent = `Current: ${item.name}`;
nodeStatus.textContent = "Queuing...";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try { try {
const genResp = await fetch(`/outfit/${item.slug}/generate`, { const genResp = await fetch(`/outfit/${item.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
await waitForCompletion(currentPromptId); // Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
const finResp = await fetch(`/outfit/${item.slug}/finalize_generation/${currentPromptId}`, { let completed = 0;
method: 'POST', let currentItem = '';
body: new URLSearchParams({ 'action': 'replace' }) await Promise.all(jobs.map(async ({ item, jobId }) => {
}); currentItem = item.name;
const finData = await finResp.json(); itemNameText.textContent = `Processing: ${currentItem}`;
try {
if (finData.success) { const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`); const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`); const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
img.src = finData.image_url;
img.classList.remove('d-none');
}
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${item.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; completed++;
} const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Outfit Generation Complete!"; statusText.textContent = 'Batch Outfit Generation Complete!';
itemNameText.textContent = ""; itemNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
regenAllBtn.disabled = false; regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -101,11 +101,10 @@
const SG_CAT = {{ sg_category | tojson }}; const SG_CAT = {{ sg_category | tojson }};
const SG_SLUG = {{ sg_entity.slug | tojson }}; const SG_SLUG = {{ sg_entity.slug | tojson }};
const SG_WS = {{ COMFYUI_WS_URL | tojson }};
const SG_CLIENT_ID = 'sg_' + Math.random().toString(36).slice(2, 10);
let sgRunning = false; let sgRunning = false;
let sgShouldStop = false; let sgShouldStop = false;
let sgQueuedJobs = []; // track all queued job IDs so stop can cancel them
// ---- helpers ---- // ---- helpers ----
@@ -241,52 +240,23 @@
sgHighlightBounds(); sgHighlightBounds();
} }
// ---- WebSocket wait ---- // ---- Job queue wait ----
function sgWaitForCompletion(promptId) { function sgWaitForJob(jobId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let ws; const poll = setInterval(async () => {
try { try {
ws = new WebSocket(`${SG_WS}?clientId=${SG_CLIENT_ID}`); const resp = await fetch(`/api/queue/${jobId}/status`);
} catch (e) { const data = await resp.json();
// Fall back to polling if WS unavailable if (data.status === 'done') { clearInterval(poll); resolve(data); }
sgPollUntilDone(promptId).then(resolve).catch(reject); else if (data.status === 'failed' || data.status === 'removed') {
return; clearInterval(poll); reject(new Error(data.error || 'Job failed'));
} }
} catch (err) { console.error('[Strengths] poll error:', err); }
const timeout = setTimeout(() => { }, 1500);
ws.close();
sgPollUntilDone(promptId).then(resolve).catch(reject);
}, 120000);
ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
if (msg.type === 'executing' && msg.data && msg.data.prompt_id === promptId) {
if (msg.data.node === null) {
clearTimeout(timeout);
ws.close();
resolve();
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
sgPollUntilDone(promptId).then(resolve).catch(reject);
};
}); });
} }
async function sgPollUntilDone(promptId) {
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 2000));
const r = await fetch(`/check_status/${promptId}`);
const d = await r.json();
if (d.status === 'complete' || d.status === 'finished' || d.done) return;
}
}
// ---- main flow ---- // ---- main flow ----
async function sgClearImages() { async function sgClearImages() {
@@ -310,64 +280,65 @@
const steps = sgBuildSteps(min, max, sgGetInterval()); const steps = sgBuildSteps(min, max, sgGetInterval());
if (!steps.length) return; if (!steps.length) return;
// Clear any previous set before starting a new one
await sgClearImages(); await sgClearImages();
sgRunning = true; sgRunning = true;
sgShouldStop = false; sgShouldStop = false;
sgQueuedJobs = [];
document.getElementById('sg-btn-run').classList.add('d-none'); document.getElementById('sg-btn-run').classList.add('d-none');
document.getElementById('sg-btn-stop').classList.remove('d-none'); document.getElementById('sg-btn-stop').classList.remove('d-none');
document.getElementById('sg-progress').classList.remove('d-none'); document.getElementById('sg-progress').classList.remove('d-none');
for (let i = 0; i < steps.length; i++) {
if (sgShouldStop) break;
const sv = steps[i];
const pct = Math.round(((i) / steps.length) * 100);
document.getElementById('sg-progress-bar').style.width = pct + '%';
document.getElementById('sg-progress-label').textContent =
`${i} / ${steps.length} \u2014 weight: ${sv}`;
try {
// Queue one generation
// Pick up the character currently selected on this detail page (if any)
const charSelect = document.getElementById('character_select'); const charSelect = document.getElementById('character_select');
const charSlug = charSelect ? charSelect.value : ''; const charSlug = charSelect ? charSelect.value : '';
const formData = new URLSearchParams({
strength_value: sv, // Phase 1: Queue all steps upfront so generation continues even if the page is navigated away
seed: seed, document.getElementById('sg-progress-bar').style.width = '100%';
client_id: SG_CLIENT_ID, document.getElementById('sg-progress-bar').classList.add('progress-bar-striped', 'progress-bar-animated');
character_slug: charSlug, for (let i = 0; i < steps.length; i++) {
}); if (sgShouldStop) break;
const sv = steps[i];
document.getElementById('sg-progress-label').textContent =
`Queuing ${i + 1} / ${steps.length} \u2014 weight: ${sv}`;
try {
const formData = new URLSearchParams({ strength_value: sv, seed, character_slug: charSlug });
const queueResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/generate`, { const queueResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/generate`, {
method: 'POST', method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData, body: formData,
}); });
const queueData = await queueResp.json(); const queueData = await queueResp.json();
if (!queueData.prompt_id) throw new Error('No prompt_id returned'); if (!queueData.job_id) throw new Error('No job_id returned');
sgQueuedJobs.push({ jobId: queueData.job_id, sv });
} catch (err) {
console.error('[Strengths] queue error:', sv, err);
}
}
await sgWaitForCompletion(queueData.prompt_id); // Phase 2: Poll all jobs concurrently; show results as each finishes
document.getElementById('sg-progress-bar').classList.remove('progress-bar-striped', 'progress-bar-animated');
document.getElementById('sg-progress-bar').style.width = '0%';
// Finalize let completed = 0;
const finData = new URLSearchParams({ strength_value: sv, seed: seed }); await Promise.all(sgQueuedJobs.map(async ({ jobId, sv }) => {
const finResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/finalize/${queueData.prompt_id}`, { try {
method: 'POST', const jobResult = await sgWaitForJob(jobId);
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, if (jobResult.result && jobResult.result.image_url) {
body: finData, sgAddImage(jobResult.result.image_url, sv);
});
const finJson = await finResp.json();
if (finJson.success && finJson.image_url) {
sgAddImage(finJson.image_url, sv);
} }
} catch (err) { } catch (err) {
console.error('[Strengths] step error:', sv, err); console.error('[Strengths] job error:', sv, err);
}
} }
completed++;
const pct = Math.round((completed / sgQueuedJobs.length) * 100);
document.getElementById('sg-progress-bar').style.width = pct + '%';
document.getElementById('sg-progress-label').textContent =
`${completed} / ${sgQueuedJobs.length} done`;
}));
document.getElementById('sg-progress-bar').style.width = '100%'; document.getElementById('sg-progress-bar').style.width = '100%';
document.getElementById('sg-progress-label').textContent = document.getElementById('sg-progress-label').textContent =
`Done \u2014 ${steps.length} images generated`; `Done \u2014 ${sgQueuedJobs.length} images generated`;
setTimeout(() => document.getElementById('sg-progress').classList.add('d-none'), 3000); setTimeout(() => document.getElementById('sg-progress').classList.add('d-none'), 3000);
document.getElementById('sg-btn-stop').classList.add('d-none'); document.getElementById('sg-btn-stop').classList.add('d-none');
@@ -377,6 +348,10 @@
function sgStop() { function sgStop() {
sgShouldStop = true; sgShouldStop = true;
// Cancel any pending (not yet processing) queued jobs
sgQueuedJobs.forEach(({ jobId }) => {
fetch(`/api/queue/${jobId}/remove`, { method: 'POST' }).catch(() => {});
});
document.getElementById('sg-btn-stop').classList.add('d-none'); document.getElementById('sg-btn-stop').classList.add('d-none');
document.getElementById('sg-btn-run').classList.remove('d-none'); document.getElementById('sg-btn-run').classList.remove('d-none');
} }

View File

@@ -0,0 +1,80 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Create Preset</h1>
<a href="{{ url_for('presets_index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row justify-content-center">
<div class="col-md-7">
<div class="card">
<div class="card-header bg-dark text-white">New Preset</div>
<div class="card-body">
<form action="{{ url_for('create_preset') }}" method="post">
<div class="mb-3">
<label for="name" class="form-label">Preset Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual">
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
<label class="form-check-label" for="use_llm">Use AI to build preset from description</label>
</div>
</div>
<div class="mb-3" id="description-section">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="5"
placeholder="Describe the kind of generations you want this preset to produce. Include the type of character, setting, mood, outfit style, etc.
Example: A random character in a cheerful beach scene, wearing a swimsuit, with a random action pose. Always include identity and wardrobe fields. Randomise headwear and accessories. No style LoRA needed."></textarea>
<div class="form-text">The AI will set entity IDs (specific, random, or none) and field toggles (on/off/random) based on your description.</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success" id="create-btn">Create Preset</button>
</div>
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header">About Presets</div>
<div class="card-body text-muted small">
<p>A <strong>preset</strong> is a saved generation recipe. It stores:</p>
<ul>
<li><strong>Entity selections</strong> — which character, action, scene, etc. to use. Set to a specific ID, <span class="badge bg-warning text-dark">Random</span> (pick at generation time), or None.</li>
<li><strong>Field toggles</strong> — which prompt fields to include. Each can be <span class="badge bg-success">ON</span>, <span class="badge bg-secondary">OFF</span>, or <span class="badge bg-warning text-dark">RNG</span> (randomly decide each generation).</li>
</ul>
<p class="mb-0">After creation you'll be taken to the edit page to review and adjust the AI's choices before generating.</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('use_llm').addEventListener('change', function() {
document.getElementById('description-section').style.display = this.checked ? '' : 'none';
});
document.querySelector('form').addEventListener('submit', function() {
const btn = document.getElementById('create-btn');
if (document.getElementById('use_llm').checked) {
btn.disabled = true;
btn.textContent = 'Generating with AI...';
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,352 @@
{% extends "layout.html" %}
{% block content %}
<!-- JSON Editor Modal -->
<div class="modal fade" id="jsonEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit JSON — {{ preset.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" id="json-simple-tab" type="button">Simple</button></li>
<li class="nav-item"><button class="nav-link" id="json-advanced-tab" type="button">Advanced JSON</button></li>
</ul>
<div id="json-editor-error" class="alert alert-danger d-none"></div>
<div id="json-simple-panel"></div>
<div id="json-advanced-panel" class="d-none">
<textarea id="json-editor-textarea" class="form-control font-monospace" rows="25" spellcheck="false"></textarea>
</div>
<script type="application/json" id="json-raw-data">{{ preset.data | tojson }}</script>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="json-save-btn">Save</button>
</div>
</div>
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 text-center">
<img id="modalImage" src="" alt="Enlarged Image" class="img-fluid" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<a href="{{ url_for('presets_index') }}" class="btn btn-sm btn-outline-secondary me-2">Back to Library</a>
<h3 class="d-inline-block mb-0">{{ preset.name }}</h3>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('edit_preset', slug=preset.slug) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">JSON</button>
<form action="{{ url_for('clone_preset', slug=preset.slug) }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-secondary">Clone</button>
</form>
</div>
</div>
<div class="row">
<!-- Left: image + generate -->
<div class="col-md-4">
<div class="card mb-3">
<div class="img-container" style="height:auto;min-height:400px;cursor:pointer;"
data-bs-toggle="modal" data-bs-target="#imageModal"
onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')">
{% if preset.image_path %}
<img src="{{ url_for('static', filename='uploads/' + preset.image_path) }}"
alt="{{ preset.name }}" class="img-fluid"
data-preview-path="{{ preset.image_path }}">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height:400px;">
<span class="text-muted">No Image</span>
</div>
{% endif %}
</div>
<div class="card-body">
<form action="{{ url_for('upload_preset_image', slug=preset.slug) }}" method="post" enctype="multipart/form-data" class="mb-3">
<label class="form-label text-muted small">Update Cover Image</label>
<div class="input-group input-group-sm">
<input class="form-control" type="file" name="image" required>
<button type="submit" class="btn btn-outline-primary">Upload</button>
</div>
</form>
<form id="generate-form" action="{{ url_for('generate_preset_image', slug=preset.slug) }}" method="post">
<div class="d-grid gap-2">
<button type="submit" name="action" value="preview" class="btn btn-success">Generate Preview</button>
<button type="submit" name="action" value="replace" class="btn btn-outline-warning btn-sm">Generate &amp; Set Cover</button>
</div>
</form>
</div>
</div>
<!-- Selected Preview -->
<div class="card mb-3" id="preview-pane" {% if not preview_path %}style="display:none"{% endif %}>
<div class="card-header d-flex justify-content-between align-items-center py-1">
<small class="fw-semibold">Selected Preview</small>
</div>
<div class="card-body p-1">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_path) if preview_path else '' }}"
class="img-fluid rounded" alt="Preview">
</div>
<div class="card-footer p-2">
<form action="{{ url_for('replace_preset_cover_from_preview', slug=preset.slug) }}" method="post">
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_path or '' }}">
<button type="submit" class="btn btn-sm btn-warning w-100">Set as Cover</button>
</form>
</div>
</div>
</div>
<!-- Right: preset summary -->
<div class="col-md-8">
{% macro toggle_badge(val) %}
{% if val == 'random' %}<span class="badge bg-warning text-dark">RNG</span>
{% elif val %}<span class="badge bg-success">ON</span>
{% else %}<span class="badge bg-secondary">OFF</span>{% endif %}
{% endmacro %}
{% macro entity_badge(val) %}
{% if val == 'random' %}<span class="badge bg-warning text-dark">Random</span>
{% elif val %}<span class="badge bg-info text-dark">{{ val | replace('_', ' ') | title }}</span>
{% else %}<span class="badge bg-secondary">None</span>{% endif %}
{% endmacro %}
<!-- Character -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<strong>Character</strong>
{{ entity_badge(preset.data.character.character_id) }}
</div>
<div class="card-body py-2">
{% set char_fields = preset.data.character.fields %}
<div class="mb-2">
<small class="text-muted fw-semibold d-block mb-1">Identity</small>
<div class="d-flex flex-wrap gap-1">
{% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
<span>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(char_fields.identity.get(k, true)) }}
</span>
{% endfor %}
</div>
</div>
<div class="mb-2">
<small class="text-muted fw-semibold d-block mb-1">Defaults</small>
<div class="d-flex flex-wrap gap-1">
{% for k in ['expression','pose','scene'] %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
<span>{{ k }}</span>
{{ toggle_badge(char_fields.defaults.get(k, false)) }}
</span>
{% endfor %}
</div>
</div>
{% set wd = char_fields.wardrobe %}
<div>
<small class="text-muted fw-semibold d-block mb-1">Wardrobe
<span class="badge bg-light text-dark border ms-1">outfit: {{ wd.get('outfit', 'default') }}</span>
</small>
<div class="d-flex flex-wrap gap-1">
{% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-2 py-1 small">
<span>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(wd.fields.get(k, true)) }}
</span>
{% endfor %}
</div>
</div>
<div class="mt-2">
<small class="text-muted">LoRA:</small>
{% if preset.data.character.use_lora %}<span class="badge bg-success">ON</span>{% else %}<span class="badge bg-secondary">OFF</span>{% endif %}
</div>
</div>
</div>
<!-- Secondary resources row -->
<div class="row g-2 mb-3">
{% for section, label, field_key, field_keys in [
('outfit', 'Outfit', 'outfit_id', []),
('action', 'Action', 'action_id', ['full_body','additional','head','eyes','arms','hands']),
('style', 'Style', 'style_id', []),
('scene', 'Scene', 'scene_id', ['background','foreground','furniture','colors','lighting','theme']),
('detailer', 'Detailer', 'detailer_id', []),
] %}
{% set sec = preset.data.get(section, {}) %}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center py-1">
<small class="fw-semibold">{{ label }}</small>
{{ entity_badge(sec.get(field_key)) }}
</div>
{% if field_keys %}
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-1">
{% for k in field_keys %}
<span class="badge-field d-flex align-items-center gap-1 border rounded px-1 py-1" style="font-size:0.7rem">
<span>{{ k | replace('_', ' ') }}</span>
{{ toggle_badge(sec.get('fields', {}).get(k, true)) }}
</span>
{% endfor %}
</div>
<div class="mt-1">
<small class="text-muted">LoRA:</small>
{% if sec.get('use_lora', true) %}<span class="badge bg-success">ON</span>{% else %}<span class="badge bg-secondary">OFF</span>{% endif %}
</div>
</div>
{% else %}
<div class="card-body py-2">
<small class="text-muted">LoRA:</small>
{% if sec.get('use_lora', true) %}<span class="badge bg-success">ON</span>{% else %}<span class="badge bg-secondary">OFF</span>{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Look & Checkpoint -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-1"><small class="fw-semibold">Look</small></div>
<div class="card-body py-2">{{ entity_badge(preset.data.get('look', {}).get('look_id')) }}</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-1"><small class="fw-semibold">Checkpoint</small></div>
<div class="card-body py-2">{{ entity_badge(preset.data.get('checkpoint', {}).get('checkpoint_path')) }}</div>
</div>
</div>
</div>
<!-- Tags -->
{% if preset.data.tags %}
<div class="card mb-3">
<div class="card-header py-2"><strong>Extra Tags</strong></div>
<div class="card-body py-2">
{% for tag in preset.data.tags %}
<span class="badge bg-light text-dark border me-1">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Generated images -->
{% set upload_dir = 'static/uploads/presets/' + preset.slug %}
{% if preset.image_path or True %}
<div class="card">
<div class="card-header py-2"><strong>Generated Images</strong></div>
<div class="card-body py-2">
<div class="row g-2" id="generated-images">
{% if preset.image_path %}
<div class="col-4 col-md-3">
<img src="{{ url_for('static', filename='uploads/' + preset.image_path) }}"
class="img-fluid rounded" style="cursor:pointer"
data-preview-path="{{ preset.image_path }}"
onclick="selectPreview('{{ preset.image_path }}', this.src)">
</div>
{% endif %}
</div>
<p id="no-images-msg" class="text-muted small mt-2 {% if preset.image_path %}d-none{% endif %}">No generated images yet.</p>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Job polling
let currentJobId = null;
document.getElementById('generate-form').addEventListener('submit', function(e) {
e.preventDefault();
const btn = e.submitter;
const actionVal = btn.value;
const formData = new FormData(this);
formData.set('action', actionVal);
btn.disabled = true;
btn.textContent = 'Generating...';
fetch(this.action, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData
})
.then(r => r.json())
.then(data => {
if (data.job_id) {
currentJobId = data.job_id;
pollJob(currentJobId, btn, actionVal);
} else {
btn.disabled = false;
btn.textContent = btn.dataset.label || 'Generate Preview';
alert('Error: ' + (data.error || 'Unknown error'));
}
})
.catch(() => { btn.disabled = false; });
});
function pollJob(jobId, btn, actionVal) {
fetch('/api/queue/' + jobId + '/status')
.then(r => r.json())
.then(data => {
if (data.status === 'done' && data.result) {
btn.disabled = false;
btn.textContent = btn.getAttribute('data-orig') || btn.textContent.replace('Generating...', 'Generate Preview');
// Add to gallery
const img = document.createElement('img');
img.src = data.result.image_url;
img.className = 'img-fluid rounded';
img.style.cursor = 'pointer';
img.dataset.previewPath = data.result.relative_path;
img.addEventListener('click', () => selectPreview(data.result.relative_path, img.src));
const col = document.createElement('div');
col.className = 'col-4 col-md-3';
col.appendChild(img);
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
selectPreview(data.result.relative_path, data.result.image_url);
} else if (data.status === 'failed') {
btn.disabled = false;
btn.textContent = 'Generate Preview';
alert('Generation failed: ' + (data.error || 'Unknown error'));
} else {
setTimeout(() => pollJob(jobId, btn, actionVal), 1500);
}
})
.catch(() => setTimeout(() => pollJob(jobId, btn, actionVal), 3000));
}
function selectPreview(relativePath, imageUrl) {
document.getElementById('preview-path').value = relativePath;
document.getElementById('preview-img').src = imageUrl;
document.getElementById('preview-pane').style.display = '';
}
function showImage(src) {
if (src) document.getElementById('modalImage').src = src;
}
// Delegate click on generated images
document.addEventListener('click', function(e) {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
// JSON editor
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
</script>
{% endblock %}

276
templates/presets/edit.html Normal file
View File

@@ -0,0 +1,276 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Edit Preset: {{ preset.name }}</h1>
<a href="{{ url_for('preset_detail', slug=preset.slug) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
{% macro toggle_group(name, val) %}
{# 3-way toggle: OFF / RNG / ON — renders as Bootstrap btn-group radio #}
{% set v = val | string | lower %}
<div class="btn-group btn-group-sm toggle-group" role="group" data-field="{{ name }}">
<input type="radio" class="btn-check" name="{{ name }}" id="{{ name }}_off" value="false" autocomplete="off" {% if v == 'false' %}checked{% endif %}>
<label class="btn btn-outline-secondary" for="{{ name }}_off">OFF</label>
<input type="radio" class="btn-check" name="{{ name }}" id="{{ name }}_rng" value="random" autocomplete="off" {% if v == 'random' %}checked{% endif %}>
<label class="btn btn-outline-warning" for="{{ name }}_rng">RNG</label>
<input type="radio" class="btn-check" name="{{ name }}" id="{{ name }}_on" value="true" autocomplete="off" {% if v not in ['false', 'random'] %}checked{% endif %}>
<label class="btn btn-outline-success" for="{{ name }}_on">ON</label>
</div>
{% endmacro %}
{% macro entity_select(name, items, id_attr, current_val, include_random=true) %}
<select class="form-select form-select-sm" name="{{ name }}">
<option value="">— None —</option>
{% if include_random %}<option value="random" {% if current_val == 'random' %}selected{% endif %}>🎲 Random</option>{% endif %}
{% for item in items %}
{% set item_id = item | attr(id_attr) %}
<option value="{{ item_id }}" {% if current_val == item_id %}selected{% endif %}>{{ item.name }}</option>
{% endfor %}
</select>
{% endmacro %}
<form action="{{ url_for('edit_preset', slug=preset.slug) }}" method="post">
{% set d = preset.data %}
{% set char_cfg = d.get('character', {}) %}
{% set char_fields = char_cfg.get('fields', {}) %}
{% set id_fields = char_fields.get('identity', {}) %}
{% set def_fields = char_fields.get('defaults', {}) %}
{% set wd_cfg = char_fields.get('wardrobe', {}) %}
{% set wd_fields = wd_cfg.get('fields', {}) %}
<div class="row">
<div class="col-md-8">
<!-- Basic Info -->
<div class="card mb-4">
<div class="card-header bg-dark text-white">Basic Information</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Preset Name</label>
<input type="text" class="form-control" name="preset_name" value="{{ preset.name }}" required>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Preset ID</label>
<input type="text" class="form-control form-control-sm" value="{{ preset.preset_id }}" disabled>
</div>
<div class="mb-3">
<label class="form-label">Extra Tags <span class="text-muted small">(comma-separated)</span></label>
<input type="text" class="form-control" name="tags" value="{{ d.get('tags', []) | join(', ') }}">
</div>
</div>
</div>
<!-- Character -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<strong>Character</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="char_use_lora" id="char_use_lora" {% if char_cfg.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white small" for="char_use_lora">Use LoRA</label>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Character</label>
{{ entity_select('char_character_id', characters, 'character_id', char_cfg.get('character_id')) }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Identity Fields</label>
<div class="row g-2">
{% for k in ['base_specs','hair','eyes','hands','arms','torso','pelvis','legs','feet','extra'] %}
<div class="col-6 col-sm-4 col-md-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
{{ toggle_group('id_' + k, id_fields.get(k, true)) }}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Default Fields</label>
<div class="row g-2">
{% for k in ['expression','pose','scene'] %}
<div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k }}</small>
{{ toggle_group('def_' + k, def_fields.get(k, false)) }}
</div>
</div>
{% endfor %}
</div>
</div>
<div>
<label class="form-label fw-semibold">Wardrobe Fields</label>
<div class="mb-2">
<label class="form-label small text-muted">Active outfit name</label>
<input type="text" class="form-control form-control-sm" name="wardrobe_outfit"
value="{{ wd_cfg.get('outfit', 'default') }}" placeholder="default">
</div>
<div class="row g-2">
{% for k in ['full_body','headwear','top','bottom','legwear','footwear','hands','gloves','accessories'] %}
<div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
{{ toggle_group('wd_' + k, wd_fields.get(k, true)) }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Action -->
{% set act = d.get('action', {}) %}
<div class="card mb-4">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<strong>Action</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="action_use_lora" id="action_use_lora" {% if act.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white small" for="action_use_lora">Use LoRA</label>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Action</label>
{{ entity_select('action_id', actions, 'action_id', act.get('action_id')) }}
</div>
<label class="form-label fw-semibold">Fields</label>
<div class="row g-2">
{% for k in ['full_body','additional','head','eyes','arms','hands'] %}
<div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k | replace('_', ' ') }}</small>
{{ toggle_group('act_' + k, act.get('fields', {}).get(k, true)) }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Style / Scene / Detailer -->
<div class="row g-3 mb-4">
{% set sty = d.get('style', {}) %}
<div class="col-md-4">
<div class="card h-100">
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center py-2">
<strong>Style</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="style_use_lora" id="style_use_lora" {% if sty.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white" for="style_use_lora" style="font-size:0.75rem">LoRA</label>
</div>
</div>
<div class="card-body">
{{ entity_select('style_id', styles, 'style_id', sty.get('style_id')) }}
</div>
</div>
</div>
{% set det = d.get('detailer', {}) %}
<div class="col-md-4">
<div class="card h-100">
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center py-2">
<strong>Detailer</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="detailer_use_lora" id="detailer_use_lora" {% if det.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white" for="detailer_use_lora" style="font-size:0.75rem">LoRA</label>
</div>
</div>
<div class="card-body">
{{ entity_select('detailer_id', detailers, 'detailer_id', det.get('detailer_id')) }}
</div>
</div>
</div>
{% set lk = d.get('look', {}) %}
<div class="col-md-4">
<div class="card h-100">
<div class="card-header bg-warning text-dark py-2"><strong>Look</strong> <small class="text-muted">(overrides char LoRA)</small></div>
<div class="card-body">
{{ entity_select('look_id', looks, 'look_id', lk.get('look_id')) }}
</div>
</div>
</div>
</div>
<!-- Scene -->
{% set scn = d.get('scene', {}) %}
<div class="card mb-4">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<strong>Scene</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="scene_use_lora" id="scene_use_lora" {% if scn.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white small" for="scene_use_lora">Use LoRA</label>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Scene</label>
{{ entity_select('scene_id', scenes, 'scene_id', scn.get('scene_id')) }}
</div>
<label class="form-label fw-semibold">Fields</label>
<div class="row g-2">
{% for k in ['background','foreground','furniture','colors','lighting','theme'] %}
<div class="col-6 col-sm-4">
<div class="d-flex justify-content-between align-items-center border rounded p-2">
<small>{{ k }}</small>
{{ toggle_group('scn_' + k, scn.get('fields', {}).get(k, true)) }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Outfit + Checkpoint -->
<div class="row g-3 mb-4">
{% set out = d.get('outfit', {}) %}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center py-2">
<strong>Outfit</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" name="outfit_use_lora" id="outfit_use_lora" {% if out.get('use_lora', true) %}checked{% endif %}>
<label class="form-check-label text-white" for="outfit_use_lora" style="font-size:0.75rem">LoRA</label>
</div>
</div>
<div class="card-body">
{{ entity_select('outfit_id', outfits, 'outfit_id', out.get('outfit_id')) }}
<small class="text-muted">Selecting an outfit overrides the character's wardrobe.</small>
</div>
</div>
</div>
{% set ckpt = d.get('checkpoint', {}) %}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2"><strong>Checkpoint</strong></div>
<div class="card-body">
<select class="form-select form-select-sm" name="checkpoint_path">
<option value="">— Use session default —</option>
<option value="random" {% if ckpt.get('checkpoint_path') == 'random' %}selected{% endif %}>🎲 Random</option>
{% for ck in checkpoints %}
<option value="{{ ck.checkpoint_path }}" {% if ckpt.get('checkpoint_path') == ck.checkpoint_path %}selected{% endif %}>{{ ck.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 pb-4">
<button type="submit" class="btn btn-primary">Save Preset</button>
<a href="{{ url_for('preset_detail', slug=preset.slug) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Preset Library</h2>
<div class="d-flex gap-1 align-items-center">
<a href="{{ url_for('create_preset') }}" class="btn btn-sm btn-success">Create New Preset</a>
<form action="{{ url_for('rescan_presets') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan preset files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for preset in presets %}
<div class="col">
<div class="card h-100 character-card" onclick="window.location.href='{{ url_for('preset_detail', slug=preset.slug) }}'">
<div class="img-container">
{% if preset.image_path %}
<img src="{{ url_for('static', filename='uploads/' + preset.image_path) }}" alt="{{ preset.name }}">
{% else %}
<span class="text-muted">No Image</span>
{% endif %}
</div>
<div class="card-body p-2">
<h6 class="card-title text-center mb-1">{{ preset.name }}</h6>
<p class="card-text small text-center text-muted mb-0">
{% set parts = [] %}
{% if preset.data.character and preset.data.character.character_id %}
{% set _ = parts.append(preset.data.character.character_id | replace('_', ' ') | title) %}
{% endif %}
{% if preset.data.action and preset.data.action.action_id %}
{% set _ = parts.append('+ action') %}
{% endif %}
{% if preset.data.scene and preset.data.scene.scene_id %}
{% set _ = parts.append('+ scene') %}
{% endif %}
{{ parts | join(' · ') }}
</p>
</div>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
<small class="text-muted">preset</small>
<div class="d-flex gap-1">
<a href="{{ url_for('edit_preset', slug=preset.slug) }}" class="btn btn-xs btn-outline-secondary" onclick="event.stopPropagation()">Edit</a>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<p class="text-muted">No presets found. <a href="{{ url_for('create_preset') }}">Create your first preset</a> or add JSON files to <code>data/presets/</code> and rescan.</p>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -56,7 +56,7 @@
<div class="card mb-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').src)">
{% if scene.image_path %} {% 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 %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
@@ -100,35 +100,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2"> <small>Selected Preview</small>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)"> <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" 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> </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>
<div class="col-md-8"> <div class="col-md-8">
@@ -145,7 +130,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-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>
</div> </div>
@@ -255,7 +240,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -281,185 +267,75 @@
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context'); const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => { charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') { charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
}); });
// Generate a unique client ID function selectPreview(relativePath, imageUrl) {
const clientId = 'scene_detail_' + Math.random().toString(36).substring(2, 15); if (!relativePath) return;
previewImg.src = imageUrl;
// ComfyUI WebSocket previewPath.value = relativePath;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
const nodeNames = { previewHeader.classList.replace('bg-secondary', 'bg-success');
"3": "Sampling", previewCard.classList.replace('border-secondary', 'border-success');
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding Image",
"9": "Saving Image"
};
let currentPromptId = null;
let resolveCompletion = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (!currentPromptId) {
const queueRemaining = msg.data.status.exec_info.queue_remaining;
if (queueRemaining > 0) {
progressLabel.textContent = `Queue position: ${queueRemaining}`;
} }
}
}
else if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node; document.addEventListener('click', e => {
if (nodeId === null) { const img = e.target.closest('img[data-preview-path]');
// Execution finished via WebSocket if (img) selectPreview(img.dataset.previewPath, img.src);
console.log('Finished via WebSocket');
if (resolveCompletion) resolveCompletion();
} else {
const nodeName = nodeNames[nodeId] || `Processing...`;
progressLabel.textContent = nodeName;
}
}
}); });
async function waitForCompletion(promptId) { async function waitForJob(jobId) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const checkResolve = () => { const poll = setInterval(async () => {
clearInterval(pollInterval);
resolve();
};
resolveCompletion = checkResolve;
// Fallback polling in case WebSocket is blocked (403)
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
console.log('Finished via Polling'); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
checkResolve(); else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} } catch (err) { console.error('Poll error:', err); }
} catch (err) { console.error('Polling error:', err); } }, 1500);
}, 2000);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', 'preview'); formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData, });
headers: { 'X-Requested-With': 'XMLHttpRequest' } const data = await response.json();
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'); }
}); });
const data = await response.json();
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentPromptId = data.prompt_id;
progressLabel.textContent = 'Queued...';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
// Wait for completion (WebSocket or Polling)
await waitForCompletion(currentPromptId);
// Finalize
finalizeGeneration(currentPromptId);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
});
async function finalizeGeneration(promptId) {
progressLabel.textContent = 'Saving image...';
const url = `/scene/{{ scene.slug }}/finalize_generation/${promptId}`;
const formData = new FormData();
formData.append('action', 'preview');
try {
const response = await fetch(url, { method: 'POST', body: formData });
const data = await response.json();
if (data.success) {
// Update preview image
previewImg.src = data.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) {
replaceBtn.disabled = false;
const form = replaceBtn.closest('form');
if (form) {
form.action = `/scene/{{ scene.slug }}/replace_cover_from_preview`;
}
}
} else {
alert('Save failed: ' + data.error);
}
} catch (err) {
console.error(err);
alert('Finalize request failed');
} finally {
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/scene/{{ scene.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -467,7 +343,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -478,8 +354,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -495,52 +372,35 @@
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const 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 fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
currentPromptId = data.prompt_id; batchBar.style.width = '0%';
await waitForCompletion(currentPromptId); let done = 0;
const total = pending.length;
progressLabel.textContent = 'Saving image...'; batchLabel.textContent = `0 / ${total} complete`;
const finalFD = new FormData(); await Promise.all(pending.map(({ char, jobId }) =>
finalFD.append('action', 'preview'); waitForJob(jobId).then(result => {
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); done++;
const finalData = await finalResp.json(); batchBar.style.width = `${Math.round((done / total) * 100)}%`;
if (finalData.success) { batchLabel.textContent = `${done} / ${total} complete`;
addToPreviewGallery(finalData.image_url, char.name); if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
previewImg.src = finalData.image_url; }).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
if (previewCard) previewCard.classList.remove('d-none'); ));
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
@@ -552,10 +412,9 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); 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) }}'); initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
}); });

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="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>
@@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for scene in scenes %} {% for scene in scenes %}
<div class="col" id="card-{{ scene.slug }}"> <div class="col" id="card-{{ scene.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/scene/{{ scene.slug }}'"> <div class="card h-100 character-card" onclick="window.location.href='/scene/{{ scene.slug }}'">
@@ -102,71 +102,18 @@
const sceneNameText = document.getElementById('current-scene-name'); const sceneNameText = document.getElementById('current-scene-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'scenes_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
const nodeNames = {
"3": "Sampling",
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding",
"9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || `Processing...`;
stepProgressText.textContent = "";
if (nodeId !== "3") {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
} else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -184,65 +131,67 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const scene of missing) { for (const scene of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating Scenes: ${completed + 1} / ${missing.length}`;
sceneNameText.textContent = `Current: ${scene.name}`;
nodeStatus.textContent = "Queuing...";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try { try {
const genResp = await fetch(`/scene/${scene.slug}/generate`, { const genResp = await fetch(`/scene/${scene.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item: scene, jobId: genData.job_id });
} catch (err) {
await waitForCompletion(currentPromptId); console.error(`Failed to queue ${scene.name}:`, err);
const finResp = await fetch(`/scene/${scene.slug}/finalize_generation/${currentPromptId}`, {
method: 'POST',
body: new URLSearchParams({ 'action': 'replace' })
});
const finData = await finResp.json();
if (finData.success) {
const img = document.getElementById(`img-${scene.slug}`);
const noImgSpan = document.getElementById(`no-img-${scene.slug}`);
if (img) {
img.src = finData.image_url;
img.classList.remove('d-none');
} }
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
itemNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${scene.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; completed++;
} const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Scene Generation Complete!"; statusText.textContent = 'Batch Scene Generation Complete!';
sceneNameText.textContent = ""; sceneNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000); setTimeout(() => container.classList.add('d-none'), 5000);
} }
batchBtn.addEventListener('click', async () => { batchBtn.addEventListener('click', async () => {

View File

@@ -73,6 +73,70 @@
</div> </div>
</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"> <div class="d-grid mt-4">
<button type="submit" class="btn btn-primary btn-lg">Save All Settings</button> <button type="submit" class="btn btn-primary btn-lg">Save All Settings</button>
</div> </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 // Local Model Loading
const connectLocalBtn = document.getElementById('connect-local-btn'); const connectLocalBtn = document.getElementById('connect-local-btn');
const localModelSelect = document.getElementById('local_model'); const localModelSelect = document.getElementById('local_model');

View File

@@ -56,7 +56,7 @@
<div class="card mb-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').src)">
{% if style.image_path %} {% 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 %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;"> <div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
<span class="text-muted">No Image Attached</span> <span class="text-muted">No Image Attached</span>
@@ -100,35 +100,20 @@
</div> </div>
</div> </div>
{% if preview_image %} <div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card mb-4 border-success"> <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">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center p-2"> <small>Selected Preview</small>
<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"> <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> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)"> <div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" 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> </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>
<div class="col-md-8"> <div class="col-md-8">
@@ -145,7 +130,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-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>
</div> </div>
@@ -247,7 +232,8 @@
class="img-fluid rounded" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" 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> </div>
{% else %} {% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div> <div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -273,183 +259,75 @@
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card'); const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img'); 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 charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context'); const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => { charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') { charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
}); });
// Generate a unique client ID function selectPreview(relativePath, imageUrl) {
const clientId = 'style_detail_' + Math.random().toString(36).substring(2, 15); if (!relativePath) return;
previewImg.src = imageUrl;
// ComfyUI WebSocket previewPath.value = relativePath;
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
const nodeNames = { previewHeader.classList.replace('bg-secondary', 'bg-success');
"3": "Sampling", previewCard.classList.replace('border-secondary', 'border-success');
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding Image",
"9": "Saving Image"
};
let currentPromptId = null;
let currentAction = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (!currentPromptId) {
const queueRemaining = msg.data.status.exec_info.queue_remaining;
if (queueRemaining > 0) {
progressLabel.textContent = `Queue position: ${queueRemaining}`;
} }
}
}
else if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node; document.addEventListener('click', e => {
if (nodeId === null) { const img = e.target.closest('img[data-preview-path]');
// Execution finished via WebSocket if (img) selectPreview(img.dataset.previewPath, img.src);
console.log('Finished via WebSocket');
if (resolveCompletion) resolveCompletion();
} else {
const nodeName = nodeNames[nodeId] || `Processing...`;
progressLabel.textContent = nodeName;
}
}
}); });
let resolveCompletion = null; async function waitForJob(jobId) {
async function waitForCompletion(promptId) { return new Promise((resolve, reject) => {
return new Promise((resolve) => { const poll = setInterval(async () => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveCompletion = checkResolve;
// Fallback polling in case WebSocket is blocked (403)
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); 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('Polling error:', err); } } catch (err) { console.error('Poll error:', err); }
}, 2000); }, 1500);
}); });
} }
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
const submitter = e.submitter; const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return; if (!submitter || submitter.value !== 'preview') return;
e.preventDefault(); e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('action', currentAction); formData.append('action', 'preview');
formData.append('client_id', clientId);
progressContainer.classList.remove('d-none'); progressContainer.classList.remove('d-none');
progressBar.style.width = '0%'; progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.textContent = '0%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Starting...'; progressLabel.textContent = 'Queuing…';
try { try {
const response = await fetch(form.getAttribute('action'), { const response = await fetch(form.getAttribute('action'), {
method: 'POST', method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
body: formData, });
headers: { 'X-Requested-With': 'XMLHttpRequest' } const data = await response.json();
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'); }
}); });
const data = await response.json();
if (data.error) {
alert('Error: ' + data.error);
progressContainer.classList.add('d-none');
return;
}
currentPromptId = data.prompt_id;
progressLabel.textContent = 'Queued...';
progressBar.style.width = '100%';
progressBar.textContent = 'Queued';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
await waitForCompletion(currentPromptId);
finalizeGeneration(currentPromptId, currentAction);
currentPromptId = null;
} catch (err) {
console.error(err);
alert('Request failed');
progressContainer.classList.add('d-none');
}
});
async function finalizeGeneration(promptId, action) {
progressLabel.textContent = 'Saving image...';
const url = `/style/{{ style.slug }}/finalize_generation/${promptId}`;
const formData = new FormData();
formData.append('action', 'preview'); // Always save as preview
try {
const response = await fetch(url, { method: 'POST', body: formData });
const data = await response.json();
if (data.success) {
previewImg.src = data.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) {
replaceBtn.disabled = false;
const form = replaceBtn.closest('form');
if (form) {
form.action = `/style/{{ style.slug }}/replace_cover_from_preview`;
}
}
} else {
alert('Save failed: ' + data.error);
}
} catch (err) {
console.error(err);
alert('Finalize request failed');
} finally {
progressContainer.classList.add('d-none');
}
}
// Batch: Generate All Characters
const allCharacters = [ const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %} {% endfor %}
]; ];
const finalizeBaseUrl = '/style/{{ style.slug }}/finalize_generation';
let stopBatch = false; let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn'); const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn'); const stopAllBtn = document.getElementById('stop-all-btn');
@@ -457,7 +335,7 @@
const batchLabel = document.getElementById('batch-label'); const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar'); const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) { function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery'); const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty'); const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
@@ -468,8 +346,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;" style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)" onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal" data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}"> 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>`; </div>`;
gallery.insertBefore(col, gallery.firstChild); gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge'); const badge = document.querySelector('#previews-tab .badge');
@@ -485,52 +364,35 @@
batchProgress.classList.remove('d-none'); batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show(); bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form'); const 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 fd = new FormData(); const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value)); genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug); fd.append('character_slug', char.slug);
fd.append('action', 'preview'); fd.append('action', 'preview');
fd.append('client_id', clientId);
try { try {
progressContainer.classList.remove('d-none'); const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressLabel.textContent = `${char.name}: Starting...`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json(); const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; } if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
currentPromptId = data.prompt_id; batchBar.style.width = '0%';
await waitForCompletion(currentPromptId); let done = 0;
const total = pending.length;
progressLabel.textContent = 'Saving image...'; batchLabel.textContent = `0 / ${total} complete`;
const finalFD = new FormData(); await Promise.all(pending.map(({ char, jobId }) =>
finalFD.append('action', 'preview'); waitForJob(jobId).then(result => {
const finalResp = await fetch(`${finalizeBaseUrl}/${currentPromptId}`, { method: 'POST', body: finalFD }); done++;
const finalData = await finalResp.json(); batchBar.style.width = `${Math.round((done / total) * 100)}%`;
if (finalData.success) { batchLabel.textContent = `${done} / ${total} complete`;
addToPreviewGallery(finalData.image_url, char.name); if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
previewImg.src = finalData.image_url; }).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
if (previewCard) previewCard.classList.remove('d-none'); ));
}
currentPromptId = null;
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentPromptId = null;
} finally {
progressContainer.classList.add('d-none');
}
}
batchBar.style.width = '100%'; batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!'; batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
@@ -542,10 +404,9 @@
stopAllBtn.addEventListener('click', () => { stopAllBtn.addEventListener('click', () => {
stopBatch = true; stopBatch = true;
stopAllBtn.classList.add('d-none'); 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) }}'); initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
}); });

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <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"> <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="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> <button id="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>
@@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for style in styles %} {% for style in styles %}
<div class="col" id="card-{{ style.slug }}"> <div class="col" id="card-{{ style.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/style/{{ style.slug }}'"> <div class="card h-100 character-card" onclick="window.location.href='/style/{{ style.slug }}'">
@@ -102,71 +102,18 @@
const styleNameText = document.getElementById('current-style-name'); const styleNameText = document.getElementById('current-style-name');
const stepProgressText = document.getElementById('current-step-progress'); const stepProgressText = document.getElementById('current-step-progress');
const clientId = 'styles_batch_' + Math.random().toString(36).substring(2, 15); async function waitForJob(jobId) {
const socket = new WebSocket('{{ COMFYUI_WS_URL }}?clientId=' + clientId); return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
const nodeNames = {
"3": "Sampling",
"11": "Face Detailing",
"13": "Hand Detailing",
"4": "Loading Models",
"16": "Character LoRA",
"17": "Outfit LoRA",
"18": "Action LoRA",
"19": "Style/Detailer LoRA",
"8": "Decoding",
"9": "Saving"
};
let currentPromptId = null;
let resolveGeneration = null;
socket.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'progress') {
if (msg.data.prompt_id !== currentPromptId) return;
const value = msg.data.value;
const max = msg.data.max;
const percent = Math.round((value / max) * 100);
stepProgressText.textContent = `${percent}%`;
taskProgressBar.style.width = `${percent}%`;
taskProgressBar.textContent = `${percent}%`;
taskProgressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
else if (msg.type === 'executing') {
if (msg.data.prompt_id !== currentPromptId) return;
const nodeId = msg.data.node;
if (nodeId === null) {
if (resolveGeneration) resolveGeneration();
} else {
nodeStatus.textContent = nodeNames[nodeId] || `Processing...`;
stepProgressText.textContent = "";
if (nodeId !== "3") {
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = nodeNames[nodeId] || 'Processing...';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
});
async function waitForCompletion(promptId) {
return new Promise((resolve) => {
const checkResolve = () => {
clearInterval(pollInterval);
resolve();
};
resolveGeneration = checkResolve;
const pollInterval = setInterval(async () => {
try { try {
const resp = await fetch(`/check_status/${promptId}`); const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json(); const data = await resp.json();
if (data.status === 'finished') { if (data.status === 'done') { clearInterval(poll); resolve(data); }
checkResolve(); else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
} else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {} } catch (err) {}
}, 2000); }, 1500);
}); });
} }
@@ -184,61 +131,63 @@
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
container.classList.remove('d-none'); container.classList.remove('d-none');
let completed = 0; // Phase 1: Queue all jobs upfront
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
nodeStatus.textContent = 'Queuing…';
const jobs = [];
for (const style of missing) { for (const style of missing) {
const percent = Math.round((completed / missing.length) * 100); statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating Styles: ${completed + 1} / ${missing.length}`;
styleNameText.textContent = `Current: ${style.name}`;
nodeStatus.textContent = "Queuing...";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = 'Queued';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
try { try {
const genResp = await fetch(`/style/${style.slug}/generate`, { const genResp = await fetch(`/style/${style.slug}/generate`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
'action': 'replace',
'client_id': clientId,
'character_slug': '__random__'
}),
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const genData = await genResp.json(); const genData = await genResp.json();
currentPromptId = genData.prompt_id; if (genData.job_id) jobs.push({ item: style, jobId: genData.job_id });
} catch (err) {
await waitForCompletion(currentPromptId); console.error(`Failed to queue ${style.name}:`, err);
const finResp = await fetch(`/style/${style.slug}/finalize_generation/${currentPromptId}`, {
method: 'POST',
body: new URLSearchParams({ 'action': 'replace' })
});
const finData = await finResp.json();
if (finData.success) {
const img = document.getElementById(`img-${style.slug}`);
const noImgSpan = document.getElementById(`no-img-${style.slug}`);
if (img) {
img.src = finData.image_url;
img.classList.remove('d-none');
} }
}
// Phase 2: Poll all concurrently
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
styleNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none'); if (noImgSpan) noImgSpan.classList.add('d-none');
} }
} catch (err) { } catch (err) {
console.error(`Failed for ${style.name}:`, err); console.error(`Failed for ${item.name}:`, err);
} }
completed++; completed++;
} const pct = Math.round((completed / jobs.length) * 100);
progressBar.style.width = `${pct}%`;
progressBar.textContent = `${pct}%`;
statusText.textContent = `${completed} / ${jobs.length} done`;
}));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
progressBar.textContent = '100%'; progressBar.textContent = '100%';
statusText.textContent = "Batch Style Generation Complete!"; statusText.textContent = 'Batch Style Generation Complete!';
styleNameText.textContent = ""; styleNameText.textContent = '';
nodeStatus.textContent = "Done"; nodeStatus.textContent = 'Done';
stepProgressText.textContent = ""; stepProgressText.textContent = '';
taskProgressBar.style.width = '0%'; taskProgressBar.style.width = '0%';
taskProgressBar.textContent = ''; taskProgressBar.textContent = '';
batchBtn.disabled = false; batchBtn.disabled = false;

187
test_character_mcp.py Normal file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
Test script for the Character Details MCP server.
Tests all available tools to verify functionality.
"""
import asyncio
import json
import sys
# MCP dependency with graceful fallback
try:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
print("=" * 80)
print("ERROR: MCP package is not installed.")
print("Install it with: pip install mcp")
print("=" * 80)
sys.exit(1)
async def test_character_mcp():
"""Test the Character Details MCP server tools."""
# Server parameters - using uv to run the character-details server
server_params = StdioServerParameters(
command="uv",
args=[
"run",
"--directory",
"tools/character-mcp",
"character-details"
],
env=None
)
print("=" * 80)
print("Testing Character Details MCP Server")
print("=" * 80)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize the session
await session.initialize()
# List available tools
print("\n📋 Available Tools:")
print("-" * 80)
tools = await session.list_tools()
for tool in tools.tools:
print(f"{tool.name}: {tool.description}")
# Test 1: Get character details for Aerith Gainsborough
print("\n\n🔍 Test 1: Getting character details for Aerith Gainsborough")
print("-" * 80)
try:
result = await session.call_tool(
"get_character",
arguments={
"name": "Aerith Gainsborough",
"franchise": "Final Fantasy VII"
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
# Print first 500 chars to see structure
text = content.text
print(f"\nReceived {len(text)} characters of data")
print("\nFirst 500 characters:")
print(text[:500])
print("...")
except Exception as e:
print(f"❌ Error: {e}")
# Test 2: List cached characters
print("\n\n📚 Test 2: Listing cached characters")
print("-" * 80)
try:
result = await session.call_tool("list_characters", arguments={})
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
print(content.text)
except Exception as e:
print(f"❌ Error: {e}")
# Test 3: Generate image prompt
print("\n\n🎨 Test 3: Generating image prompt for Aerith")
print("-" * 80)
try:
result = await session.call_tool(
"generate_image_prompt",
arguments={
"name": "Aerith Gainsborough",
"franchise": "Final Fantasy VII",
"style": "anime",
"scene": "tending flowers in the church",
"extra_tags": "soft lighting, peaceful atmosphere"
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
print("\nGenerated prompt:")
print(content.text)
except Exception as e:
print(f"❌ Error: {e}")
# Test 4: Generate story context
print("\n\n📖 Test 4: Generating story context for Aerith")
print("-" * 80)
try:
result = await session.call_tool(
"generate_story_context",
arguments={
"name": "Aerith Gainsborough",
"franchise": "Final Fantasy VII",
"scenario": "Meeting Cloud for the first time in the Sector 5 church",
"include_abilities": True
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
text = content.text
print(f"\nReceived {len(text)} characters of story context")
print("\nFirst 800 characters:")
print(text[:800])
print("...")
except Exception as e:
print(f"❌ Error: {e}")
# Test 5: Try a different character - Princess Peach
print("\n\n👑 Test 5: Getting character details for Princess Peach")
print("-" * 80)
try:
result = await session.call_tool(
"get_character",
arguments={
"name": "Princess Peach",
"franchise": "Super Mario"
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
text = content.text
print(f"\nReceived {len(text)} characters of data")
print("\nFirst 500 characters:")
print(text[:500])
print("...")
except Exception as e:
print(f"❌ Error: {e}")
# Test 6: Generate image prompt for Princess Peach
print("\n\n🎨 Test 6: Generating image prompt for Princess Peach")
print("-" * 80)
try:
result = await session.call_tool(
"generate_image_prompt",
arguments={
"name": "Princess Peach",
"franchise": "Super Mario",
"style": "anime",
"scene": "in her castle throne room",
"extra_tags": "elegant, royal, pink dress"
}
)
print("✅ Success!")
for content in result.content:
if hasattr(content, 'text'):
print("\nGenerated prompt:")
print(content.text)
except Exception as e:
print(f"❌ Error: {e}")
print("\n" + "=" * 80)
print("Testing Complete!")
print("=" * 80)
if __name__ == "__main__":
asyncio.run(test_character_mcp())