10 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
48 changed files with 3722 additions and 2438 deletions

8
.dockerignore Normal file
View File

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

View File

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

29
Dockerfile Normal file
View File

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

View File

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

2436
app.py

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -22,7 +22,7 @@
"default": {
"full_body": "",
"headwear": "",
"top": "black crop top, blue and silver motorcycle jacket",
"top": "black crop top, blue and silver jacket",
"bottom": "black leather pants",
"legwear": "",
"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",
"steps": 25,
"base_positive": "anime, cute, loli, moe",
"cfg": 5,
"checkpoint_name": "zukiNewCuteILL_newV20.safetensors",
"checkpoint_path": "Illustrious/zukiNewCuteILL_newV20.safetensors",
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"sampler_name": "euler_ancestral",
"steps": 25,
"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_name": "Illustrious/Clothing/GoldDripNunChainDressLingerieILL.safetensors",
"lora_name": "",
"lora_triggers": "",
"lora_weight": 0.8,
"lora_triggers": "GoldDripNunChainDressLingerieILL",
"lora_weight_min": 0.8,
"lora_weight_max": 0.8
"lora_weight_max": 0.8,
"lora_weight_min": 0.8
},
"outfit_id": "golddripnunchaindresslingerieill",
"outfit_name": "Nun (with Gold)",
"tags": [
"nun",
"veil",
@@ -33,5 +23,15 @@
"dripping",
"gold",
"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_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_name": "Illustrious/Looks/JN_Tron_Bonne_Illus.safetensors",
"lora_weight": 0.8,
@@ -11,6 +9,8 @@
"lora_weight_min": 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": [
"tron_bonne_(mega_man)",
"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_name": "7B Dream",
"style": {
"artist_name": "7b_Dream",
"artistic_style": "3d"
"artist_name": "7b",
"artistic_style": "3d, blender, semi-realistic"
},
"lora": {
"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):
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):
id = db.Column(db.Integer, primary_key=True)
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')
local_base_url = db.Column(db.String(255), nullable=True)
local_model = db.Column(db.String(100), nullable=True)
# LoRA directories (absolute paths on disk)
lora_dir_characters = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Looks')
lora_dir_outfits = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Clothing')
lora_dir_actions = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Poses')
lora_dir_styles = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Styles')
lora_dir_scenes = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Backgrounds')
lora_dir_detailers = db.Column(db.String(500), default='/ImageModels/lora/Illustrious/Detailers')
# Checkpoint scan directories (comma-separated list of absolute paths)
checkpoint_dirs = db.Column(db.String(1000), default='/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob')
# Default checkpoint path (persisted across server restarts)
default_checkpoint = db.Column(db.String(500), nullable=True)
def __repr__(self):
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 ------------------------------------------------ */
:root {
--bg-base: #0f0f1a;
--bg-card: #1a1a2e;
--bg-raised: #222240;
--bg-input: #16162a;
--accent: #6c63ff;
--accent-dim: #4f46e5;
--accent-glow: rgba(108, 99, 255, 0.22);
--border: #2d2d5e;
--border-light: #3a3a6a;
--text: #e2e2f0;
--text-muted: #8888aa;
--text-dim: #5555aa;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
--info: #38bdf8;
--radius: 10px;
/* Backgrounds */
--bg-base: #07070f;
--bg-card: #0c0c1c;
--bg-raised: #111128;
--bg-input: #09091a;
/* Accent — violet */
--accent: #8b7eff;
--accent-dim: #6c5ce7;
--accent-bright:#b0a8ff;
--accent-glow: rgba(139, 126, 255, 0.14);
--accent-glow-strong: rgba(139, 126, 255, 0.32);
/* Borders */
--border: #16163a;
--border-light: #21214a;
--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 ----------------------------------------------------- */
body {
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);
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;
}
a { color: var(--accent); }
a:hover { color: #9d98ff; }
a { color: var(--accent-bright); text-decoration: none; }
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 */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-card); }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
@@ -44,109 +80,108 @@ a:hover { color: #9d98ff; }
Navbar
============================================================ */
.navbar {
background: rgba(15, 15, 26, 0.95) !important;
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 0.55rem 0;
background: rgba(7, 7, 15, 0.97) !important;
backdrop-filter: blur(24px) saturate(1.3);
border-bottom: 1px solid var(--border) !important;
padding: 0.5rem 0;
}
.navbar-brand {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: 0.12em;
letter-spacing: 0.18em;
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-text-fill-color: transparent;
background-clip: text;
}
.navbar-brand:hover {
background: linear-gradient(90deg, #c4b5fd, #818cf8, #d8b4fe);
background: linear-gradient(135deg, #d8b4fe 0%, #b0a8ff 45%, #93c5fd 100%);
-webkit-background-clip: text;
background-clip: text;
}
.navbar-logo {
height: 28px;
height: 26px;
width: auto;
}
/* All nav outline-light buttons become understated link-style items */
/* Nav links — borderless, subtle hover */
.navbar .btn-outline-light {
border: none;
color: var(--text-muted) !important;
background: transparent;
font-size: 0.82rem;
padding: 0.28rem 0.6rem;
border-radius: 6px;
font-size: 0.79rem;
font-weight: 500;
padding: 0.28rem 0.58rem;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
letter-spacing: 0.01em;
}
.navbar .btn-outline-light:hover,
.navbar .btn-outline-light:focus {
color: var(--text) !important;
background: rgba(255, 255, 255, 0.07);
background: rgba(139, 126, 255, 0.08);
box-shadow: none;
}
/* Generator and Gallery get accent tint */
.navbar .btn-outline-light[href="/generator"],
.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="/gallery"]:hover {
background: var(--accent-glow);
color: #b8b4ff !important;
color: #c4b5fd !important;
}
/* Create Character */
.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;
font-size: 0.82rem;
font-size: 0.79rem;
font-weight: 500;
padding: 0.28rem 0.7rem;
border-radius: 6px;
border-radius: var(--radius-sm);
transition: all 0.15s;
}
.navbar .btn-outline-success:hover {
background: rgba(34, 197, 94, 0.12);
background: rgba(52, 211, 153, 0.1);
border-color: var(--success);
box-shadow: none;
color: var(--success) !important;
}
/* Vertical divider in navbar */
.navbar .vr {
background-color: var(--border);
background-color: var(--border-light);
opacity: 1;
}
/* ---- Queue button in navbar ---- */
/* Queue button */
.queue-btn {
position: relative;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1rem;
padding: 0.2rem 0.5rem;
border-radius: 6px;
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.07);
}
.queue-btn-active {
color: var(--accent) !important;
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.6rem;
font-size: 0.58rem;
font-weight: 700;
min-width: 16px;
height: 16px;
@@ -157,14 +192,12 @@ a:hover { color: #9d98ff; }
padding: 0 3px;
line-height: 1;
}
.queue-icon {
font-size: 0.95rem;
}
.queue-icon { font-size: 0.88rem; }
/* Queue status dots */
.queue-status-dot {
width: 8px;
height: 8px;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
@@ -174,14 +207,13 @@ a:hover { color: #9d98ff; }
.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); }
.queue-status-removed { background: var(--border-light); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
50% { opacity: 0.35; }
}
/* Small button variant for queue actions */
.btn-xs {
padding: 0.1rem 0.35rem;
font-size: 0.7rem;
@@ -189,6 +221,19 @@ a:hover { color: #9d98ff; }
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
============================================================ */
@@ -204,10 +249,10 @@ a:hover { color: #9d98ff; }
border-bottom: 1px solid var(--border) !important;
color: var(--text) !important;
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-dark,
.card-header.bg-secondary,
@@ -224,19 +269,76 @@ a:hover { color: #9d98ff; }
.card-body { color: var(--text); }
/* Character / category card hover */
/* ============================================================
Gallery Cards (.character-card)
============================================================ */
.character-card {
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 {
transform: translateY(-4px);
box-shadow: 0 8px 32px var(--accent-glow);
transform: translateY(-5px);
box-shadow: 0 20px 60px rgba(4, 4, 10, 0.7), 0 0 0 1px var(--accent);
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 {
height: 300px;
@@ -246,6 +348,7 @@ a:hover { color: #9d98ff; }
align-items: center;
justify-content: center;
border-radius: var(--radius) var(--radius) 0 0;
position: relative;
}
.img-container img {
width: 100%;
@@ -253,6 +356,34 @@ a:hover { color: #9d98ff; }
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 */
#result-container {
background-color: var(--bg-raised) !important;
@@ -265,15 +396,16 @@ a:hover { color: #9d98ff; }
.form-control,
.form-select {
background-color: var(--bg-input) !important;
border-color: var(--border) !important;
border-color: var(--border-light) !important;
color: var(--text) !important;
border-radius: 6px;
border-radius: var(--radius-sm);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.875rem;
}
.form-control:focus,
.form-select:focus {
border-color: var(--accent) !important;
box-shadow: 0 0 0 3px var(--accent-glow) !important;
border-color: var(--border-focus) !important;
box-shadow: 0 0 0 3px rgba(85, 72, 200, 0.22) !important;
background-color: var(--bg-input) !important;
color: var(--text) !important;
}
@@ -282,10 +414,11 @@ a:hover { color: #9d98ff; }
.form-select:disabled {
background-color: var(--bg-raised) !important;
color: var(--text-muted) !important;
opacity: 0.65;
}
.form-label { color: var(--text-muted); font-size: 0.82rem; font-weight: 500; }
.form-text { color: var(--text-dim) !important; }
.form-label { color: var(--text-muted); font-size: 0.8rem; font-weight: 500; margin-bottom: 0.3rem; }
.form-text { color: var(--text-dim) !important; font-size: 0.77rem; }
.form-check-input {
background-color: var(--bg-input);
@@ -295,8 +428,11 @@ a:hover { color: #9d98ff; }
background-color: var(--accent);
border-color: var(--accent);
}
.form-check-input:focus { box-shadow: 0 0 0 3px var(--accent-glow); }
.form-check-label { color: var(--text); }
.form-check-input:focus {
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); }
@@ -304,22 +440,24 @@ option { background-color: var(--bg-raised); color: var(--text); }
Buttons
============================================================ */
.btn-primary {
background-color: var(--accent);
border-color: var(--accent-dim);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dim) 100%);
border: 1px solid var(--accent-dim);
color: #fff;
font-weight: 500;
}
.btn-primary:hover,
.btn-primary:active,
.btn-primary:focus {
background-color: var(--accent-dim);
border-color: #3730a3;
box-shadow: 0 0 0 3px var(--accent-glow);
background: linear-gradient(135deg, var(--accent-bright) 0%, var(--accent) 100%);
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(139, 126, 255, 0.22);
color: #fff;
}
.btn-outline-primary {
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
.btn-outline-primary:hover,
.btn-outline-primary:active {
@@ -332,9 +470,10 @@ option { background-color: var(--bg-raised); color: var(--text); }
background-color: var(--bg-raised);
border-color: var(--border-light);
color: var(--text);
font-weight: 500;
}
.btn-secondary:hover {
background-color: #2a2a50;
background-color: #1c1c38;
border-color: var(--border-light);
color: var(--text);
}
@@ -342,6 +481,7 @@ option { background-color: var(--bg-raised); color: var(--text); }
.btn-outline-secondary {
border-color: var(--border-light);
color: var(--text-muted);
font-weight: 500;
}
.btn-outline-secondary:hover,
.btn-outline-secondary:active {
@@ -352,36 +492,36 @@ option { background-color: var(--bg-raised); color: var(--text); }
/* Active resolution preset */
.btn-secondary.preset-btn {
background-color: var(--accent);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dim) 100%);
border-color: var(--accent-dim);
color: #fff;
}
.btn-outline-success { border-color: var(--success); color: var(--success); }
.btn-outline-success:hover { background-color: rgba(34,197,94,.12); border-color: var(--success); color: var(--success); }
.btn-success { background-color: var(--success); border-color: #16a34a; color: #fff; }
.btn-success:hover { background-color: #16a34a; border-color: #15803d; color: #fff; }
.btn-outline-success { border-color: var(--success); color: var(--success); font-weight: 500; }
.btn-outline-success:hover { background-color: rgba(52,211,153,.1); border-color: var(--success); color: var(--success); }
.btn-success { background-color: #059669; border-color: #047857; color: #fff; font-weight: 500; }
.btn-success:hover { background-color: #047857; border-color: #065f46; color: #fff; }
.btn-outline-danger { border-color: var(--danger); color: var(--danger); }
.btn-outline-danger:hover { background-color: rgba(239,68,68,.12); border-color: var(--danger); color: var(--danger); }
.btn-danger { background-color: var(--danger); border-color: #dc2626; color: #fff; }
.btn-danger:hover { background-color: #dc2626; border-color: #b91c1c; color: #fff; }
.btn-outline-danger { border-color: var(--danger); color: var(--danger); font-weight: 500; }
.btn-outline-danger:hover { background-color: rgba(248,113,113,.1); border-color: var(--danger); color: var(--danger); }
.btn-danger { background-color: #dc2626; border-color: #b91c1c; color: #fff; font-weight: 500; }
.btn-danger:hover { background-color: #b91c1c; border-color: #991b1b; color: #fff; }
.btn-outline-warning { border-color: var(--warning); color: var(--warning); }
.btn-outline-warning:hover { background-color: rgba(245,158,11,.12); 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(251,191,36,.1); border-color: var(--warning); color: var(--warning); }
.btn-light, .btn-outline-light {
background-color: rgba(255,255,255,.08);
background-color: rgba(255,255,255,.06);
border-color: var(--border-light);
color: var(--text);
font-weight: 500;
}
.btn-light:hover, .btn-outline-light:hover {
background-color: rgba(255,255,255,.14);
background-color: rgba(255,255,255,.1);
color: var(--text);
}
/* Close button override (visible on dark bg) */
.btn-close { filter: invert(1) brightness(0.8); }
.btn-close { filter: invert(1) brightness(0.7); }
/* ============================================================
Accordion
@@ -390,60 +530,57 @@ option { background-color: var(--bg-raised); color: var(--text); }
background-color: var(--bg-card) !important;
border-color: var(--border) !important;
}
.accordion-button {
background-color: var(--bg-raised) !important;
color: var(--text) !important;
font-size: 0.88rem;
font-size: 0.875rem;
font-weight: 500;
}
.accordion-button:not(.collapsed) {
background-color: #252450 !important;
color: var(--text) !important;
background-color: #181835 !important;
color: var(--accent-bright) !important;
box-shadow: inset 0 -1px 0 var(--border);
}
/* Make the chevron arrow visible on dark bg */
.accordion-button::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-body { background-color: var(--bg-card); padding: 0.5rem; }
/* Mix & match list items */
.mix-item { user-select: none; border-radius: 6px; }
.mix-item:hover { background-color: rgba(108, 99, 255, 0.12) !important; }
/* N/A placeholder for items without images */
.mix-item { user-select: none; border-radius: var(--radius-sm); }
.mix-item:hover { background-color: rgba(139, 126, 255, 0.1) !important; }
.mix-item .bg-light {
background-color: var(--bg-raised) !important;
color: var(--text-dim) !important;
}
/* ============================================================
Progress bars
Progress Bars
============================================================ */
.progress {
background-color: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 999px;
}
.progress-bar {
background-color: var(--accent);
border-radius: 8px;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
border-radius: 999px;
transition: width 0.4s ease-in-out;
}
.progress-bar.bg-success { background-color: var(--success) !important; }
.progress-bar.bg-info { background-color: var(--info) !important; }
.progress-bar.bg-success { background: var(--success) !important; }
.progress-bar.bg-info { background: var(--info) !important; }
/* ============================================================
Badges
============================================================ */
.badge.bg-primary { background-color: var(--accent) !important; }
.badge.bg-secondary { background-color: #2e2e52 !important; color: var(--text-muted) !important; }
.badge.bg-info { background-color: var(--info) !important; color: #0f172a !important; }
.badge.bg-success { background-color: var(--success) !important; }
.badge.bg-secondary { background-color: #1b1b40 !important; color: var(--text-muted) !important; }
.badge.bg-info { background-color: var(--info) !important; color: #0c1a2b !important; }
.badge.bg-success { background-color: var(--success) !important; color: #012218 !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; }
/* ============================================================
@@ -451,39 +588,50 @@ option { background-color: var(--bg-raised); color: var(--text); }
============================================================ */
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border);
border: 1px solid var(--border-light);
border-radius: var(--radius);
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 {
border-bottom: 1px solid var(--border);
background-color: var(--bg-raised);
border-radius: var(--radius) var(--radius) 0 0;
padding: 1rem 1.25rem;
}
.modal-footer {
border-top: 1px solid var(--border);
background-color: var(--bg-raised);
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
============================================================ */
.alert-info {
background-color: rgba(56, 189, 248, 0.1);
border-color: rgba(56, 189, 248, 0.3);
background-color: rgba(96, 200, 255, 0.08);
border-color: rgba(96, 200, 255, 0.22);
color: var(--info);
border-radius: var(--radius-sm);
}
.alert-success {
background-color: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
background-color: rgba(52, 211, 153, 0.08);
border-color: rgba(52, 211, 153, 0.22);
color: var(--success);
border-radius: var(--radius-sm);
}
.alert-danger {
background-color: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
background-color: rgba(248, 113, 113, 0.08);
border-color: rgba(248, 113, 113, 0.22);
color: var(--danger);
border-radius: var(--radius-sm);
}
/* ============================================================
@@ -493,6 +641,7 @@ option { background-color: var(--bg-raised); color: var(--text); }
background-color: var(--bg-card);
border-color: var(--border);
color: var(--text-muted);
font-size: 0.875rem;
}
.page-link:hover {
background-color: var(--bg-raised);
@@ -511,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; }
h1, h2, h3, h4, h5, h6 { color: var(--text); }
.border-bottom { border-color: var(--border) !important; }
.border-top { border-color: var(--border) !important; }
.border { border-color: var(--border) !important; }
hr { border-color: var(--border); }
small { color: var(--text-muted); }
hr { border-color: var(--border); opacity: 1; }
small { color: var(--text-muted); font-size: 0.8em; }
.font-monospace { color: var(--text); }
/* Spinner on dark bg */
.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 {
position: relative;
@@ -539,20 +697,20 @@ small { color: var(--text-muted); }
}
.gallery-card:hover {
border-color: var(--accent);
box-shadow: 0 0 20px var(--accent-glow);
box-shadow: 0 0 28px var(--accent-glow);
}
.gallery-card img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
transition: transform 0.2s;
transition: transform 0.25s ease;
}
.gallery-card:hover img { transform: scale(1.04); }
.gallery-card .overlay {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.88));
padding: 28px 8px 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
padding: 32px 10px 10px;
opacity: 0;
transition: opacity 0.2s;
}
@@ -564,16 +722,16 @@ small { color: var(--text-muted); }
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px;
}
@media (min-width: 768px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
@media (min-width: 1200px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
@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(210px, 1fr)); } }
/* Lightbox */
#lightbox {
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;
}
#lightbox.active { display: flex; }
@@ -581,16 +739,16 @@ small { color: var(--text-muted); }
#lightbox-img {
max-width: 90vw; max-height: 80vh; object-fit: contain;
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;
}
#lightbox-meta { color: #eee; margin-top: 10px; text-align: center; font-size: .85rem; }
#lightbox-hint { color: rgba(255,255,255,.38); font-size: .75rem; margin-top: 3px; }
#lightbox-meta { color: #eee; margin-top: 12px; text-align: center; font-size: .85rem; }
#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-actions { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; gap: 8px; }
/* 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-value { font-family: monospace; word-break: break-all; color: var(--text); }
@@ -602,49 +760,10 @@ small { color: var(--text-muted); }
font-size: .8rem; font-family: monospace; margin: 2px;
color: var(--text);
}
.lora-chip .lora-strength { color: var(--accent); }
.lora-chip .lora-strength { color: var(--accent-bright); }
/* ============================================================
Misc
============================================================ */
/* 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 Indicators (navbar)
============================================================ */
.service-status {
display: inline-flex;
@@ -652,30 +771,63 @@ textarea[readonly] {
gap: 4px;
cursor: default;
user-select: none;
opacity: 0.85;
opacity: 0.8;
}
.service-status:hover { opacity: 1; }
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
transition: background-color 0.4s ease;
}
.status-dot.status-ok { background-color: #3dd68c; box-shadow: 0 0 4px #3dd68c88; }
.status-dot.status-error { background-color: #f06080; box-shadow: 0 0 4px #f0608088; }
.status-dot.status-checking { background-color: #888; animation: status-pulse 1.2s ease-in-out infinite; }
.status-dot.status-ok { background-color: #34d399; box-shadow: 0 0 5px rgba(52, 211, 153, 0.55); }
.status-dot.status-error { background-color: #f87171; box-shadow: 0 0 5px rgba(248, 113, 113, 0.55); }
.status-dot.status-checking { background-color: var(--border-light); animation: status-pulse 1.4s ease-in-out infinite; }
.status-label {
font-size: 0.7rem;
font-weight: 500;
color: rgba(255,255,255,0.65);
color: rgba(255, 255, 255, 0.45);
letter-spacing: 0.02em;
}
@keyframes status-pulse {
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="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
{% if action.image_path %}
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid">
<img src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}" class="img-fluid" data-preview-path="{{ action.image_path }}">
{% else %}
<span class="text-muted">No Image Attached</span>
{% endif %}
@@ -97,35 +97,20 @@
</div>
</div>
{% if preview_image %}
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<div class="card mb-4 {% if preview_image %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_image %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_action_cover_from_preview', slug=action.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn">Replace Cover</button>
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_image or '' }}">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_image %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) }}" alt="Preview" class="img-fluid">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_image) if preview_image else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% else %}
<div class="card mb-4 border-secondary d-none" id="preview-card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<span>Latest Preview</span>
<form action="{{ url_for('replace_action_cover_from_preview', slug=action.slug) }}" method="post" class="m-0" id="replace-cover-form">
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" disabled>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)">
<img id="preview-img" src="" alt="Preview" class="img-fluid">
</div>
</div>
</div>
{% endif %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -163,7 +148,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Gallery</a>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
</div>
@@ -288,7 +273,8 @@
class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal">
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="{{ img }}">
</div>
{% else %}
<div class="col-12 text-muted small" id="gallery-empty">No previews yet. Generate some!</div>
@@ -314,20 +300,30 @@
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
// Toggle character context info
charSelect.addEventListener('change', () => {
if (charSelect.value && charSelect.value !== '__random__') {
charContext.classList.remove('d-none');
} else {
charContext.classList.add('d-none');
}
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
let currentJobId = null;
let currentAction = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
@@ -335,17 +331,9 @@
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') {
clearInterval(poll);
resolve(data);
} else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll);
reject(new Error(data.error || 'Job failed'));
} else if (data.status === 'processing') {
progressLabel.textContent = 'Generating…';
} else {
progressLabel.textContent = 'Queued…';
}
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
@@ -355,12 +343,10 @@
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
currentAction = submitter.value;
const formData = new FormData(form);
formData.append('action', currentAction);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
@@ -368,31 +354,21 @@
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
const replaceBtn = document.getElementById('replace-cover-btn');
if (replaceBtn) replaceBtn.disabled = false;
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
@@ -400,7 +376,7 @@
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, charName) {
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
@@ -411,8 +387,9 @@
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="showImage(this.src)"
data-bs-toggle="modal" data-bs-target="#imageModal"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
@@ -427,43 +404,37 @@
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
for (let i = 0; i < allCharacters.length; i++) {
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const char = allCharacters[i];
batchBar.style.width = `${Math.round((i / allCharacters.length) * 100)}%`;
batchLabel.textContent = `Generating ${char.name} (${i + 1} / ${allCharacters.length})`;
const genForm = document.getElementById('generate-form');
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = `${char.name}: Queuing…`;
const resp = await fetch(genForm.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (data.error) { console.error(`Error for ${char.name}:`, data.error); progressContainer.classList.add('d-none'); continue; }
currentJobId = data.job_id;
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
addToPreviewGallery(jobResult.result.image_url, char.name);
previewImg.src = jobResult.result.image_url;
if (previewCard) previewCard.classList.remove('d-none');
}
} catch (err) {
console.error(`Failed for ${char.name}:`, err);
currentJobId = null;
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
@@ -474,10 +445,9 @@
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current generation...';
batchLabel.textContent = 'Stopping';
});
// JSON Editor
initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
});

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Checkpoint Gallery</h2>
<h2>Checkpoint Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
@@ -44,7 +44,7 @@
</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 %}
<div class="col" id="card-{{ ckpt.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/checkpoint/{{ ckpt.slug }}'">
@@ -115,53 +115,68 @@
regenAllBtn.disabled = true;
container.classList.remove('d-none');
let completed = 0;
for (const ckpt of missing) {
const percent = Math.round((completed / missing.length) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating: ${completed + 1} / ${missing.length}`;
ckptNameText.textContent = `Current: ${ckpt.name}`;
nodeStatus.textContent = 'Queuing…';
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = '';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
// 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 ckpt of missing) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ 'character_slug': '__random__' }),
body: new URLSearchParams({ character_slug: '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
currentJobId = genData.job_id;
if (genData.job_id) jobs.push({ item: ckpt, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${ckpt.name}:`, err);
}
}
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
// 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;
ckptNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${ckpt.slug}`);
const noImgSpan = document.getElementById(`no-img-${ckpt.slug}`);
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');
}
} catch (err) {
console.error(`Failed for ${ckpt.name}:`, err);
currentJobId = null;
console.error(`Failed for ${item.name}:`, err);
}
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.textContent = '100%';
statusText.textContent = 'Batch Generation Complete!';
statusText.textContent = 'Batch Checkpoint Generation Complete!';
ckptNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000);
setTimeout(() => container.classList.add('d-none'), 5000);
}
batchBtn.addEventListener('click', async () => {

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Detailer Gallery</h2>
<h2>Detailer Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
@@ -47,7 +47,7 @@
</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 %}
<div class="col" id="card-{{ detailer.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/detailer/{{ detailer.slug }}'">
@@ -162,7 +162,10 @@
statusText.textContent = `0 / ${jobs.length} done`;
let completed = 0;
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
detailerNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
<title>GAZE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://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="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
</head>
@@ -23,10 +23,11 @@
<a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a>
<a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a>
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
<div class="vr mx-1 d-none d-lg-block"></div>
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
<a href="/gallery" class="btn btn-sm btn-outline-light">Gallery</a>
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div>
<!-- Queue indicator -->
@@ -44,23 +45,14 @@
<span class="status-dot status-checking"></span>
<span class="status-label d-none d-xl-inline">MCP</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>
</nav>
<div class="default-checkpoint-bar border-bottom mb-4">
<div class="container d-flex align-items-center gap-2 py-2">
<small class="text-muted text-nowrap">Default checkpoint:</small>
<select id="defaultCheckpointSelect" class="form-select form-select-sm" style="max-width: 320px;">
<option value="">— workflow default —</option>
{% for ckpt in all_checkpoints %}
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
{% endfor %}
</select>
<small id="checkpointSaveStatus" class="text-muted" style="opacity:0;transition:opacity 0.5s">Saved</small>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
@@ -129,22 +121,44 @@
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
const ckptSelect = document.getElementById('defaultCheckpointSelect');
const saveStatus = document.getElementById('checkpointSaveStatus');
if (ckptSelect) {
ckptSelect.addEventListener('change', () => {
fetch('/set_default_checkpoint', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'checkpoint_path=' + encodeURIComponent(ckptSelect.value)
}).then(() => {
saveStatus.style.opacity = '1';
setTimeout(() => { saveStatus.style.opacity = '0'; }, 1500);
});
});
}
});
// ---- Loaded checkpoint → ComfyUI tooltip ----
(function() {
let _loadedCheckpoint = null;
async function pollLoadedCheckpoint() {
try {
const r = await fetch('/api/comfyui/loaded_checkpoint', { cache: 'no-store' });
const data = await r.json();
_loadedCheckpoint = data.checkpoint || null;
} catch {
_loadedCheckpoint = null;
}
updateComfyTooltip();
}
function updateComfyTooltip() {
const el = document.getElementById('status-comfyui');
if (!el) return;
const dot = el.querySelector('.status-dot');
const online = dot && dot.classList.contains('status-ok');
let text = 'ComfyUI: ' + (online ? 'online' : 'offline');
if (_loadedCheckpoint) {
const parts = _loadedCheckpoint.split(/[/\\]/);
const name = parts[parts.length - 1].replace(/\.safetensors$/, '');
text += '\n' + name;
}
el.setAttribute('data-bs-title', text);
el.setAttribute('title', text);
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) tip.setContent({ '.tooltip-inner': text });
}
// Hook into the existing status polling to refresh tooltip after status changes
window._updateComfyTooltip = updateComfyTooltip;
document.addEventListener('DOMContentLoaded', () => {
pollLoadedCheckpoint();
setInterval(pollLoadedCheckpoint, 30000);
});
})();
</script>
<script>
// ---- Resource delete modal (category galleries) ----
@@ -333,6 +347,7 @@
const services = [
{ id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' },
{ id: 'status-mcp', url: '/api/status/mcp', label: 'Danbooru MCP' },
{ id: 'status-llm', url: '/api/status/llm', label: 'LLM' },
];
function setStatus(id, label, ok) {
@@ -340,14 +355,15 @@
if (!el) return;
const dot = el.querySelector('.status-dot');
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
if (id === 'status-comfyui' && window._updateComfyTooltip) {
window._updateComfyTooltip();
return;
}
const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
el.setAttribute('data-bs-title', tooltipText);
el.setAttribute('title', tooltipText);
// Refresh tooltip instance if already initialised
const tip = bootstrap.Tooltip.getInstance(el);
if (tip) {
tip.setContent({ '.tooltip-inner': tooltipText });
}
if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
}
async function pollService(svc) {

View File

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

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Looks Gallery</h2>
<h2>Looks Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
@@ -47,7 +47,7 @@
</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 %}
<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) }}'">
@@ -59,6 +59,9 @@
<img id="img-{{ look.slug }}" src="" alt="{{ look.name }}" class="d-none">
<span id="no-img-{{ look.slug }}" class="text-muted">No Image</span>
{% endif %}
{% 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 class="card-body">
<h5 class="card-title text-center">{{ look.name }}</h5>
@@ -106,6 +109,22 @@
const stepProgressText = document.getElementById('current-step-progress');
let currentJobId = null;
let queuePollInterval = null;
async function updateCurrentJobLabel() {
try {
const resp = await fetch('/api/queue');
const data = await resp.json();
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) => {
@@ -136,30 +155,42 @@
regenAllBtn.disabled = true;
container.classList.remove('d-none');
let completed = 0;
for (const item of missing) {
const percent = Math.round((completed / missing.length) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
statusText.textContent = `Batch Generating Looks: ${completed + 1} / ${missing.length}`;
itemNameText.textContent = `Current: ${item.name}`;
nodeStatus.textContent = "Queuing…";
taskProgressBar.style.width = '100%';
taskProgressBar.textContent = '';
taskProgressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
// 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) {
statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}`;
try {
const genResp = await fetch(`/look/${item.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ 'action': 'replace' }),
body: new URLSearchParams({ action: 'replace' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
currentJobId = genData.job_id;
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
// 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`;
// Start polling queue for current job label
queuePollInterval = setInterval(updateCurrentJobLabel, 1000);
updateCurrentJobLabel(); // Initial update
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 noImgSpan = document.getElementById(`no-img-${item.slug}`);
@@ -168,22 +199,31 @@
}
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
currentJobId = null;
}
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.textContent = '100%';
statusText.textContent = "Batch Look Generation Complete!";
itemNameText.textContent = "";
nodeStatus.textContent = "Done";
stepProgressText.textContent = "";
statusText.textContent = 'Batch Look Generation Complete!';
itemNameText.textContent = '';
nodeStatus.textContent = 'Done';
stepProgressText.textContent = '';
taskProgressBar.style.width = '0%';
taskProgressBar.textContent = '';
batchBtn.disabled = false;
regenAllBtn.disabled = false;
setTimeout(() => { container.classList.add('d-none'); }, 5000);
setTimeout(() => container.classList.add('d-none'), 5000);
}
batchBtn.addEventListener('click', async () => {

View File

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

View File

@@ -2,7 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Outfit Gallery</h2>
<h2>Outfit Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
@@ -47,7 +47,7 @@
</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 %}
<div class="col" id="card-{{ 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">
<span id="no-img-{{ outfit.slug }}" class="text-muted">No Image</span>
{% 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 class="card-body">
<h5 class="card-title text-center">{{ outfit.name }}</h5>
@@ -160,7 +163,10 @@
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) {

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

View File

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

View File

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

View File

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

View File

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

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())