Add semantic tagging, search, favourite/NSFW filtering, and LLM job queue

Replaces old list-format tags (which duplicated prompt content) with structured
dict tags per category (origin_series, outfit_type, participants, style_type,
scene_type, etc.). Tags are now purely organizational metadata — removed from
the prompt pipeline entirely.

Adds is_favourite and is_nsfw columns to all 8 resource models. Favourite is
DB-only (user preference); NSFW is mirrored in JSON tags for rescan persistence.
All library pages get filter controls and favourites-first sorting.

Introduces a parallel LLM job queue (_enqueue_task + _llm_queue_worker) for
background tag regeneration, with the same status polling UI as ComfyUI jobs.
Fixes call_llm() to use has_request_context() fallback for background threads.

Adds global search (/search) across resources and gallery images, with navbar
search bar. Adds gallery image sidecar JSON for per-image favourite/NSFW metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-21 03:22:09 +00:00
parent 7d79e626a5
commit 32a73b02f5
72 changed files with 3163 additions and 2212 deletions

View File

@@ -46,6 +46,8 @@ routes/
transfer.py # Resource transfer system transfer.py # Resource transfer system
queue_api.py # /api/queue/* endpoints queue_api.py # /api/queue/* endpoints
api.py # REST API v1 (preset generation, auth) api.py # REST API v1 (preset generation, auth)
regenerate.py # Tag regeneration (single + bulk, via LLM queue)
search.py # Global search across resources and gallery images
``` ```
### Dependency Graph ### Dependency Graph
@@ -90,6 +92,8 @@ All category models (except Settings and Checkpoint) share this pattern:
- `data` — full JSON blob (SQLAlchemy JSON column) - `data` — full JSON blob (SQLAlchemy JSON column)
- `default_fields` — list of `section::key` strings saved as the user's preferred prompt fields - `default_fields` — list of `section::key` strings saved as the user's preferred prompt fields
- `image_path` — relative path under `static/uploads/` - `image_path` — relative path under `static/uploads/`
- `is_favourite` — boolean (DB-only, not in JSON; toggled from detail pages)
- `is_nsfw` — boolean (mirrored in both DB column and JSON `tags.nsfw`; synced on rescan)
### Data Flow: JSON → DB → Prompt → ComfyUI ### Data Flow: JSON → DB → Prompt → ComfyUI
@@ -156,10 +160,16 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
### `services/job_queue.py` — Background Job Queue ### `services/job_queue.py` — Background Job Queue
- **`_enqueue_job(label, workflow, finalize_fn)`** — Adds a generation job to the queue. Two independent queues with separate worker threads:
- **`_make_finalize(category, slug, db_model_class=None, action=None)`** — Factory returning a callback that retrieves the generated image from ComfyUI, saves it, and optionally updates the DB cover image.
- **ComfyUI queue** (`_job_queue` + `_queue_worker`): Image generation jobs.
- **`_enqueue_job(label, workflow, finalize_fn)`** — Adds a generation job to the queue.
- **`_make_finalize(category, slug, db_model_class=None, action=None)`** — Factory returning a callback that retrieves the generated image from ComfyUI, saves it, and optionally updates the DB cover image.
- **LLM queue** (`_llm_queue` + `_llm_queue_worker`): LLM task jobs (tag regeneration, bulk create with overwrite).
- **`_enqueue_task(label, task_fn)`** — Adds an LLM task job. `task_fn` receives the job dict and runs inside `app.app_context()`.
- **Shared**: Both queues share `_job_history` (for status lookup by job ID) and `_job_queue_lock`.
- **`_prune_job_history(max_age_seconds=3600)`** — Removes old terminal-state jobs from memory. - **`_prune_job_history(max_age_seconds=3600)`** — Removes old terminal-state jobs from memory.
- **`init_queue_worker(flask_app)`** — Stores the app reference and starts the worker thread. - **`init_queue_worker(flask_app)`** — Stores the app reference and starts both worker threads.
### `services/comfyui.py` — ComfyUI HTTP Client ### `services/comfyui.py` — ComfyUI HTTP Client
@@ -170,13 +180,14 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
### `services/llm.py` — LLM Integration ### `services/llm.py` — LLM Integration
- **`call_llm(prompt, system_prompt)`** — OpenAI-compatible chat completion supporting OpenRouter (cloud) and Ollama/LMStudio (local). Implements a tool-calling loop (up to 10 turns) using `DANBOORU_TOOLS` via MCP Docker container. - **`call_llm(prompt, system_prompt)`** — OpenAI-compatible chat completion supporting OpenRouter (cloud) and Ollama/LMStudio (local). Implements a tool-calling loop (up to 10 turns) using `DANBOORU_TOOLS` via MCP Docker container. Safe to call from background threads (uses `has_request_context()` fallback for OpenRouter HTTP-Referer header).
- **`load_prompt(filename)`** — Loads system prompt text from `data/prompts/`. - **`load_prompt(filename)`** — Loads system prompt text from `data/prompts/`.
- **`call_mcp_tool()`** — Synchronous wrapper for MCP tool calls. - **`call_mcp_tool()`** — Synchronous wrapper for MCP tool calls.
### `services/sync.py` — Data Synchronization ### `services/sync.py` — Data Synchronization
- **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category. - **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category.
- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called in every sync function on both create and update paths.
- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers. - **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers.
### `services/file_io.py` — File & DB Helpers ### `services/file_io.py` — File & DB Helpers
@@ -203,7 +214,9 @@ Some helpers are defined inside a route module's `register_routes()` since they'
- `routes/checkpoints.py`: `_build_checkpoint_workflow()` — checkpoint-specific workflow builder - `routes/checkpoints.py`: `_build_checkpoint_workflow()` — checkpoint-specific workflow builder
- `routes/strengths.py`: `_build_strengths_prompts()`, `_prepare_strengths_workflow()` — strengths gallery helpers - `routes/strengths.py`: `_build_strengths_prompts()`, `_prepare_strengths_workflow()` — strengths gallery helpers
- `routes/transfer.py`: `_create_minimal_template()` — transfer template builder - `routes/transfer.py`: `_create_minimal_template()` — transfer template builder
- `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()` - `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()`, `_write_sidecar()` — gallery image sidecar JSON I/O
- `routes/regenerate.py`: Tag regeneration routes (single + category bulk + all), tag migration
- `routes/search.py`: `_search_resources()`, `_search_images()` — global search across resources and gallery
--- ---
@@ -221,7 +234,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
}, },
"styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" }, "styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" },
"lora": { "lora_name": "Illustrious/Looks/tifa.safetensors", "lora_weight": 0.8, "lora_triggers": "" }, "lora": { "lora_name": "Illustrious/Looks/tifa.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
"tags": [], "tags": { "origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false },
"participants": { "orientation": "1F", "solo_focus": "true" } "participants": { "orientation": "1F", "solo_focus": "true" }
} }
``` ```
@@ -234,7 +247,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
"outfit_name": "French Maid", "outfit_name": "French Maid",
"wardrobe": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "accessories": "" }, "wardrobe": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "accessories": "" },
"lora": { "lora_name": "Illustrious/Clothing/maid.safetensors", "lora_weight": 0.8, "lora_triggers": "" }, "lora": { "lora_name": "Illustrious/Clothing/maid.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
"tags": [] "tags": { "outfit_type": "Uniform", "nsfw": false }
} }
``` ```
@@ -245,7 +258,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
"action_name": "Sitting", "action_name": "Sitting",
"action": { "full_body": "", "additional": "", "head": "", "eyes": "", "arms": "", "hands": "" }, "action": { "full_body": "", "additional": "", "head": "", "eyes": "", "arms": "", "hands": "" },
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }, "lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
"tags": [] "tags": { "participants": "1girl", "nsfw": false }
} }
``` ```
@@ -256,7 +269,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
"scene_name": "Beach", "scene_name": "Beach",
"scene": { "background": "", "foreground": "", "furniture": "", "colors": "", "lighting": "", "theme": "" }, "scene": { "background": "", "foreground": "", "furniture": "", "colors": "", "lighting": "", "theme": "" },
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }, "lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
"tags": [] "tags": { "scene_type": "Outdoor", "nsfw": false }
} }
``` ```
@@ -266,7 +279,8 @@ Some helpers are defined inside a route module's `register_routes()` since they'
"style_id": "watercolor", "style_id": "watercolor",
"style_name": "Watercolor", "style_name": "Watercolor",
"style": { "artist_name": "", "artistic_style": "" }, "style": { "artist_name": "", "artistic_style": "" },
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" } "lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
"tags": { "style_type": "Watercolor", "nsfw": false }
} }
``` ```
@@ -277,7 +291,8 @@ Some helpers are defined inside a route module's `register_routes()` since they'
"detailer_name": "Detailed Skin", "detailer_name": "Detailed Skin",
"prompt": ["detailed skin", "pores"], "prompt": ["detailed skin", "pores"],
"focus": { "face": true, "hands": true }, "focus": { "face": true, "hands": true },
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" } "lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
"tags": { "associated_resource": "skin", "adetailer_targets": ["face", "hands"], "nsfw": false }
} }
``` ```
@@ -290,7 +305,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
"positive": "casual clothes, jeans", "positive": "casual clothes, jeans",
"negative": "revealing", "negative": "revealing",
"lora": { "lora_name": "Illustrious/Looks/tifa_casual.safetensors", "lora_weight": 0.85, "lora_triggers": "" }, "lora": { "lora_name": "Illustrious/Looks/tifa_casual.safetensors", "lora_weight": 0.85, "lora_triggers": "" },
"tags": [] "tags": { "origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false }
} }
``` ```
Looks occupy LoRA node 16, overriding the character's own LoRA. The Look's `negative` is prepended to the workflow's negative prompt. Looks occupy LoRA node 16, overriding the character's own LoRA. The Look's `negative` is prepended to the workflow's negative prompt.
@@ -329,13 +344,14 @@ Checkpoint JSONs are keyed by `checkpoint_path`. If no JSON exists for a discove
### Category Pattern (Outfits, Actions, Styles, Scenes, Detailers) ### Category Pattern (Outfits, Actions, Styles, Scenes, Detailers)
Each category follows the same URL pattern: Each category follows the same URL pattern:
- `GET /<category>/`gallery - `GET /<category>/`library with favourite/NSFW filter controls
- `GET /<category>/<slug>` — detail + generation UI - `GET /<category>/<slug>` — detail + generation UI
- `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}` - `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}`
- `POST /<category>/<slug>/replace_cover_from_preview` - `POST /<category>/<slug>/replace_cover_from_preview`
- `GET/POST /<category>/<slug>/edit` - `GET/POST /<category>/<slug>/edit`
- `POST /<category>/<slug>/upload` - `POST /<category>/<slug>/upload`
- `POST /<category>/<slug>/save_defaults` - `POST /<category>/<slug>/save_defaults`
- `POST /<category>/<slug>/favourite` — toggle `is_favourite` (AJAX)
- `POST /<category>/<slug>/clone` — duplicate entry - `POST /<category>/<slug>/clone` — duplicate entry
- `POST /<category>/<slug>/save_json` — save raw JSON (from modal editor) - `POST /<category>/<slug>/save_json` — save raw JSON (from modal editor)
- `POST /<category>/rescan` - `POST /<category>/rescan`
@@ -375,6 +391,19 @@ All generation routes use the background job queue. Frontend polls:
Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes. Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes.
### Search
- `GET /search` — global search page; query params: `q` (search term), `category` (all/characters/outfits/etc.), `nsfw` (all/sfw/nsfw), `type` (all/resources/images)
### Tag Regeneration
- `POST /api/<category>/<slug>/regenerate_tags` — single entity tag regeneration via LLM queue
- `POST /admin/bulk_regenerate_tags/<category>` — queue LLM tag regeneration for all entities in a category
- `POST /admin/bulk_regenerate_tags` — queue LLM tag regeneration for all resources across all categories
- `POST /admin/migrate_tags` — convert old list-format tags to new dict format
### Gallery Image Metadata
- `POST /gallery/image/favourite` — toggle favourite on a gallery image (writes sidecar JSON)
- `POST /gallery/image/nsfw` — toggle NSFW on a gallery image (writes sidecar JSON)
### Utilities ### Utilities
- `POST /set_default_checkpoint` — save default checkpoint to session and persist to `comfy_workflow.json` - `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) - `GET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}` — AJAX: list items without cover images (sorted by display name)
@@ -415,8 +444,10 @@ Text files in `data/prompts/` define JSON output schemas for LLM-generated entri
- `character_system.txt` — character JSON schema - `character_system.txt` — character JSON schema
- `outfit_system.txt` — outfit JSON schema - `outfit_system.txt` — outfit JSON schema
- `action_system.txt`, `scene_system.txt`, `style_system.txt`, `detailer_system.txt`, `look_system.txt`, `checkpoint_system.txt` - `action_system.txt`, `scene_system.txt`, `style_system.txt`, `detailer_system.txt`, `look_system.txt`, `checkpoint_system.txt`
- `preset_system.txt` — preset JSON schema
- `regenerate_tags_system.txt` — tag regeneration schema (all per-category tag structures)
Used by: character/outfit/action/scene/style create forms, and bulk_create routes. Used by: character/outfit/action/scene/style create forms, bulk_create routes, and tag regeneration. All system prompts include NSFW awareness preamble.
### Danbooru MCP Tools ### Danbooru MCP Tools
The LLM loop in `call_llm()` provides three tools via a Docker-based MCP server (`danbooru-mcp:latest`): The LLM loop in `call_llm()` provides three tools via a Docker-based MCP server (`danbooru-mcp:latest`):
@@ -430,6 +461,41 @@ All system prompts (`character_system.txt`, `outfit_system.txt`, `action_system.
--- ---
## Tagging System
Tags are **semantic metadata** for organizing and filtering resources. They are **not injected into generation prompts** — tags are purely for the UI (search, filtering, categorization).
### Tag Schema Per Category
| Category | Tag fields | Example |
|----------|-----------|---------|
| Character | `origin_series`, `origin_type`, `nsfw` | `{"origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false}` |
| Look | `origin_series`, `origin_type`, `nsfw` | same as Character |
| Outfit | `outfit_type`, `nsfw` | `{"outfit_type": "Uniform", "nsfw": false}` |
| Action | `participants`, `nsfw` | `{"participants": "1girl, 1boy", "nsfw": true}` |
| Style | `style_type`, `nsfw` | `{"style_type": "Anime", "nsfw": false}` |
| Scene | `scene_type`, `nsfw` | `{"scene_type": "Indoor", "nsfw": false}` |
| Detailer | `associated_resource`, `adetailer_targets`, `nsfw` | `{"associated_resource": "skin", "adetailer_targets": ["face", "hands"], "nsfw": false}` |
| Checkpoint | `art_style`, `base_model`, `nsfw` | `{"art_style": "anime", "base_model": "Illustrious", "nsfw": false}` |
### Favourite / NSFW Columns
- `is_favourite` — DB-only boolean. Toggled via `POST /<category>/<slug>/favourite`. Not stored in JSON (user preference, not asset metadata).
- `is_nsfw` — DB column **and** `tags.nsfw` in JSON. Synced from JSON on rescan via `_sync_nsfw_from_tags()`. Editable from edit pages.
### Library Filtering
All library index pages support query params:
- `?favourite=on` — show only favourites
- `?nsfw=sfw|nsfw|all` — filter by NSFW status
- Results are ordered by `is_favourite DESC, name ASC` (favourites sort first).
### Gallery Image Sidecar Files
Gallery images can have per-image favourite/NSFW metadata stored in sidecar JSON files at `{image_path}.json` (e.g. `static/uploads/characters/tifa/gen_123.png.json`). Sidecar schema: `{"is_favourite": bool, "is_nsfw": bool}`.
---
## LoRA File Paths ## LoRA File Paths
LoRA filenames in JSON are stored as paths relative to ComfyUI's `models/lora/` root: LoRA filenames in JSON are stored as paths relative to ComfyUI's `models/lora/` root:
@@ -526,5 +592,7 @@ Volumes mounted into the app container:
- **LoRA chaining**: If a LoRA node has no LoRA (name is empty/None), the node is skipped and `model_source`/`clip_source` pass through unchanged. Do not set the node inputs for skipped nodes. - **LoRA chaining**: If a LoRA node has no LoRA (name is empty/None), the node is skipped and `model_source`/`clip_source` pass through unchanged. Do not set the node inputs for skipped nodes.
- **AJAX detection**: `request.headers.get('X-Requested-With') == 'XMLHttpRequest'` determines whether to return JSON or redirect. - **AJAX detection**: `request.headers.get('X-Requested-With') == 'XMLHttpRequest'` determines whether to return JSON or redirect.
- **Session must be marked modified for JSON responses**: After setting session values in AJAX-responding routes, set `session.modified = True`. - **Session must be marked modified for JSON responses**: After setting session values in AJAX-responding routes, set `session.modified = True`.
- **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. When merging into `tags` for `build_prompt`, use `extend` for lists and `append` for strings — never append the list object itself or `", ".join()` will fail on the nested list item. - **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. In generate routes, the detailer prompt is injected directly into `prompts['main']` after `build_prompt()` returns (not via tags or `build_prompt` itself).
- **`_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"`. - **`_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"`.
- **LLM queue runs without request context**: `_enqueue_task()` callbacks execute in a background thread with only `app.app_context()`. Do not access `flask.request`, `flask.session`, or other request-scoped objects inside `task_fn`. Use `has_request_context()` guard if code is shared between HTTP handlers and background tasks.
- **Tags are metadata only**: Tags (`data['tags']`) are never injected into generation prompts. They are purely for UI filtering and search. The old pattern of `parts.extend(data.get('tags', []))` in prompt building has been removed.

14
app.py
View File

@@ -119,6 +119,20 @@ if __name__ == '__main__':
else: else:
print(f"Migration settings note ({col_name}): {e}") print(f"Migration settings note ({col_name}): {e}")
# Migration: Add is_favourite and is_nsfw columns to all resource tables
_tag_tables = ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint']
for _tbl in _tag_tables:
for _col, _type in [('is_favourite', 'BOOLEAN DEFAULT 0'), ('is_nsfw', 'BOOLEAN DEFAULT 0')]:
try:
db.session.execute(text(f'ALTER TABLE {_tbl} ADD COLUMN {_col} {_type}'))
db.session.commit()
print(f"Added {_col} column to {_tbl} table")
except Exception as e:
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
pass
else:
print(f"Migration note ({_tbl}.{_col}): {e}")
# Ensure settings exist # Ensure settings exist
if not Settings.query.first(): if not Settings.query.first():
db.session.add(Settings()) db.session.add(Settings())

View File

@@ -21,6 +21,7 @@ Structure:
"feet": "string (foot position)", "feet": "string (foot position)",
"additional": "string (extra details)" "additional": "string (extra details)"
}, },
"suppress_wardrobe": false,
"lora": { "lora": {
"lora_name": "WILL_BE_REPLACED", "lora_name": "WILL_BE_REPLACED",
"lora_weight": 1.0, "lora_weight": 1.0,
@@ -28,8 +29,16 @@ Structure:
"lora_weight_max": 1.0, "lora_weight_max": 1.0,
"lora_triggers": "WILL_BE_REPLACED" "lora_triggers": "WILL_BE_REPLACED"
}, },
"tags": ["string", "string"] "tags": {
"participants": "string (e.g. 'solo', '1girl 1boy', '2girls', '3girls 1boy')",
"nsfw": false
}
} }
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for sexual, explicit, or fetish actions.
- `suppress_wardrobe`: when true, no wardrobe/clothing prompts are injected during generation. Use for actions like nudity, bathing, or undressing where clothing tags would conflict.
Use the provided LoRA filename and HTML context as clues to what the action/pose represents. Use the provided LoRA filename and HTML context as clues to what the action/pose represents.
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields. IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1). - If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).

View File

@@ -50,6 +50,13 @@ Structure:
"lora_weight_max": 1.0, "lora_weight_max": 1.0,
"lora_triggers": "" "lora_triggers": ""
}, },
"tags": ["string", "string"] "tags": {
"origin_series": "string (the franchise/series the character is from, e.g. 'Fire Emblem', 'Spy x Family', 'Mario'. Use 'Original' if the character is not from any series)",
"origin_type": "string (one of: Anime, Video Game, Cartoon, Movie, Comic, Original)",
"nsfw": false
}
} }
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the tags.
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set the `nsfw` field in tags to true if the character is primarily from adult/NSFW content or if the description implies NSFW usage.
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the prompt field tags (identity, wardrobe, etc). The `tags` object contains semantic metadata — not Danbooru tags.

View File

@@ -16,9 +16,16 @@ Structure:
"steps": 25, "steps": 25,
"cfg": 5.0, "cfg": 5.0,
"sampler_name": "euler_ancestral", "sampler_name": "euler_ancestral",
"vae": "integrated" "vae": "integrated",
"tags": {
"art_style": "string (one of: Anime, Realistic, Cartoon, Semi-Realistic)",
"base_model": "string (one of: Illustrious, Noob — determined from the checkpoint path)",
"nsfw": false
}
} }
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the checkpoint is specifically designed for NSFW content. Determine `base_model` from the checkpoint path (e.g. 'Illustrious/model.safetensors' → 'Illustrious').
Field guidance: Field guidance:
- "base_positive": Comma-separated tags that improve output quality for this specific model. Look for recommended positive prompt tags in the HTML. - "base_positive": Comma-separated tags that improve output quality for this specific model. Look for recommended positive prompt tags in the HTML.
- "base_negative": Comma-separated tags to suppress unwanted artifacts. Look for recommended negative prompt tags in the HTML. - "base_negative": Comma-separated tags to suppress unwanted artifacts. Look for recommended negative prompt tags in the HTML.

View File

@@ -18,8 +18,16 @@ Structure:
"lora_weight_min": 0.7, "lora_weight_min": 0.7,
"lora_weight_max": 1.0, "lora_weight_max": 1.0,
"lora_triggers": "WILL_BE_REPLACED" "lora_triggers": "WILL_BE_REPLACED"
},
"tags": {
"associated_resource": "string (one of: General, Looks, Styles, Faces, NSFW — use 'General' for quality/detail enhancers that apply broadly)",
"adetailer_targets": ["string (which ADetailer regions this affects: face, hands, body, nsfw)"],
"nsfw": false
} }
} }
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for sexually explicit detail enhancers. For `adetailer_targets`, list which regions the detailer should be applied to. Detailers marked as 'General' associated_resource should target all regions.
Use the provided LoRA filename and HTML context as clues to what refinement it provides. Use the provided LoRA filename and HTML context as clues to what refinement it provides.
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields. IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1). - If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).

View File

@@ -23,8 +23,15 @@ Structure:
"lora_weight_max": 1.0, "lora_weight_max": 1.0,
"lora_triggers": "WILL_BE_REPLACED" "lora_triggers": "WILL_BE_REPLACED"
}, },
"tags": ["string", "string"] "tags": {
"origin_series": "string (the franchise/series the character look is from, e.g. 'Fire Emblem', 'Dragon Ball'. Use 'Original' if not from any series)",
"origin_type": "string (one of: Anime, Video Game, Cartoon, Movie, Comic, Original)",
"nsfw": false
}
} }
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the look is primarily NSFW.
Use the provided LoRA filename and HTML context as clues to what the character look represents. Use the provided LoRA filename and HTML context as clues to what the character look represents.
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the 'positive'/'negative' fields. IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the 'positive'/'negative' fields.
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1). - If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).

View File

@@ -30,6 +30,12 @@ Structure:
"lora_weight_max": 1.0, "lora_weight_max": 1.0,
"lora_triggers": "" "lora_triggers": ""
}, },
"tags": ["string", "string"] "tags": {
"outfit_type": "string (one of: Formal, Casual, Swimsuit, Lingerie, Underwear, Nude, Cosplay, Uniform, Fantasy, Armor, Traditional)",
"nsfw": false
}
} }
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the tags.
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for lingerie, underwear, nude, or sexually suggestive outfits.
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the prompt field tags (wardrobe fields). The `tags` object contains semantic metadata — not Danbooru tags.

View File

@@ -35,6 +35,7 @@ Structure:
"action": { "action": {
"action_id": "specific_id | random | null", "action_id": "specific_id | random | null",
"use_lora": true, "use_lora": true,
"suppress_wardrobe": null,
"fields": { "base": true, "head": true, "upper_body": true, "lower_body": true, "hands": true, "feet": false, "additional": true } "fields": { "base": true, "head": true, "upper_body": true, "lower_body": true, "hands": true, "feet": false, "additional": true }
}, },
"style": { "style_id": "specific_id | random | null", "use_lora": true }, "style": { "style_id": "specific_id | random | null", "use_lora": true },
@@ -54,6 +55,8 @@ 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 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. - 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. - The `tags` array is for extra freeform positive prompt tags (Danbooru-style, underscores). Validate them with the tools.
- `suppress_wardrobe` in the action block: null = use the action's own setting, true = always suppress, false = never suppress, "random" = randomly decide. When suppressed, no wardrobe/clothing prompts are injected.
- The `resolution` object sets image dimensions. Set `random` to true to pick a random aspect ratio each generation. When `random` is false, `width` and `height` are used directly. Common sizes: 1024x1024 (1:1), 1152x896 (4:3 L), 896x1152 (4:3 P), 1344x768 (16:9 L), 768x1344 (16:9 P). - The `resolution` object sets image dimensions. Set `random` to true to pick a random aspect ratio each generation. When `random` is false, `width` and `height` are used directly. Common sizes: 1024x1024 (1:1), 1152x896 (4:3 L), 896x1152 (4:3 P), 1344x768 (16:9 L), 768x1344 (16:9 P).
- Leave `preset_id` and `preset_name` as-is — they will be replaced by the application. - Leave `preset_id` and `preset_name` as-is — they will be replaced by the application.
- This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Consider NSFW compatibility when selecting entity combinations.
- Output ONLY valid JSON. No explanations, no markdown fences. - Output ONLY valid JSON. No explanations, no markdown fences.

View File

@@ -0,0 +1,48 @@
You are a tag regeneration assistant for an AI image generation tool. You will receive existing JSON data for a resource and must regenerate all prompt/tag fields with fresh, high-quality Danbooru-style tags.
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set the `nsfw` field in tags appropriately based on the content.
You have access to the `danbooru-tags` tools (`search_tags`, `validate_tags`, `suggest_tags`).
You MUST use these tools to ensure all prompt field tags are valid Danbooru tags.
- Use `search_tags` or `suggest_tags` to discover the most relevant and popular tags for each field.
- Use `validate_tags` to check your final selection.
- Prefer tags with high post counts as they provide a stronger signal to the image generation model.
- Use Danbooru-style tags (underscores instead of spaces, e.g., 'long_hair', 'blue_eyes').
- Keep values concise.
- Use empty strings "" for fields that are not applicable — never use words like "none" or "n/a".
Rules:
1. Output ONLY valid JSON. Do not wrap in markdown blocks.
2. PRESERVE these fields exactly as-is from the input: all *_id, *_name, lora (entire block), suppress_wardrobe, participants (in the data object, NOT in tags), character_id (on looks).
3. REGENERATE all prompt/descriptive fields with fresh, validated Danbooru tags.
4. REGENERATE the `tags` object using the structured format below (NOT a list of strings).
5. Use the resource name and any existing values as context clues, but improve and validate them.
6. Return the complete JSON object with the same structure as the input.
IMPORTANT: The `tags` field must be a JSON OBJECT (dict), not a list. Use the appropriate schema based on the resource type:
For characters:
"tags": { "origin_series": "series name or Original", "origin_type": "Anime|Video Game|Cartoon|Movie|Comic|Original", "nsfw": bool }
For looks:
"tags": { "origin_series": "series name or Original", "origin_type": "Anime|Video Game|Cartoon|Movie|Comic|Original", "nsfw": bool }
For outfits:
"tags": { "outfit_type": "Formal|Casual|Swimsuit|Lingerie|Underwear|Nude|Cosplay|Uniform|Fantasy|Armor|Traditional", "nsfw": bool }
For actions:
"tags": { "participants": "solo|1girl 1boy|2girls|etc", "nsfw": bool }
For styles:
"tags": { "style_type": "Anime|Realistic|Western|Artistic|Sketch|Watercolor|Digital|Pixel Art", "nsfw": bool }
For scenes:
"tags": { "scene_type": "Indoor|Outdoor|Fantasy|Urban|Nature|Abstract", "nsfw": bool }
For detailers:
"tags": { "associated_resource": "General|Looks|Styles|Faces|NSFW", "adetailer_targets": ["face"|"hands"|"body"|"nsfw"], "nsfw": bool }
For checkpoints:
"tags": { "art_style": "Anime|Realistic|Cartoon|Semi-Realistic", "base_model": "Illustrious|Noob", "nsfw": bool }
Determine the resource type from the input JSON structure (presence of character_id, outfit_id, action_id, etc.).

View File

@@ -27,8 +27,14 @@ Structure:
"lora_weight_max": 1.0, "lora_weight_max": 1.0,
"lora_triggers": "WILL_BE_REPLACED" "lora_triggers": "WILL_BE_REPLACED"
}, },
"tags": ["string", "string"] "tags": {
"scene_type": "string (one of: Indoor, Outdoor, Fantasy, Urban, Nature, Abstract)",
"nsfw": false
}
} }
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the scene is inherently NSFW (e.g. love hotel, dungeon).
Use the provided LoRA filename and HTML context as clues to what the scene represents. Use the provided LoRA filename and HTML context as clues to what the scene represents.
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields. IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1). - If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).

View File

@@ -21,8 +21,15 @@ Structure:
"lora_weight_min": 0.7, "lora_weight_min": 0.7,
"lora_weight_max": 1.0, "lora_weight_max": 1.0,
"lora_triggers": "WILL_BE_REPLACED" "lora_triggers": "WILL_BE_REPLACED"
},
"tags": {
"style_type": "string (one of: Anime, Realistic, Western, Artistic, Sketch, Watercolor, Digital, Pixel Art)",
"nsfw": false
} }
} }
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the style is primarily used for NSFW content.
Use the provided LoRA filename and HTML context as clues to what artist or style it represents. Use the provided LoRA filename and HTML context as clues to what artist or style it represents.
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight' and 'lora_triggers'. IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight' and 'lora_triggers'.
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1). - If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).

View File

@@ -13,6 +13,9 @@ class Character(db.Model):
image_path = db.Column(db.String(255), nullable=True) image_path = db.Column(db.String(255), nullable=True)
active_outfit = db.Column(db.String(100), default='default') active_outfit = db.Column(db.String(100), default='default')
is_favourite = db.Column(db.Boolean, default=False)
is_nsfw = db.Column(db.Boolean, default=False)
# NEW: Outfit assignment support (Phase 4) # NEW: Outfit assignment support (Phase 4)
assigned_outfit_ids = db.Column(db.JSON, default=list) # List of outfit_ids from Outfit table assigned_outfit_ids = db.Column(db.JSON, default=list) # List of outfit_ids from Outfit table
default_outfit_id = db.Column(db.String(100), default='default') # 'default' or specific outfit_id default_outfit_id = db.Column(db.String(100), default='default') # 'default' or specific outfit_id
@@ -161,6 +164,8 @@ class Look(db.Model):
data = db.Column(db.JSON, nullable=False) data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, nullable=True) default_fields = db.Column(db.JSON, nullable=True)
image_path = db.Column(db.String(255), nullable=True) image_path = db.Column(db.String(255), nullable=True)
is_favourite = db.Column(db.Boolean, default=False)
is_nsfw = db.Column(db.Boolean, default=False)
def get_linked_characters(self): def get_linked_characters(self):
"""Get all characters linked to this look.""" """Get all characters linked to this look."""
@@ -192,6 +197,8 @@ class Outfit(db.Model):
data = db.Column(db.JSON, nullable=False) data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, nullable=True) default_fields = db.Column(db.JSON, nullable=True)
image_path = db.Column(db.String(255), nullable=True) image_path = db.Column(db.String(255), nullable=True)
is_favourite = db.Column(db.Boolean, default=False)
is_nsfw = db.Column(db.Boolean, default=False)
def __repr__(self): def __repr__(self):
return f'<Outfit {self.outfit_id}>' return f'<Outfit {self.outfit_id}>'
@@ -205,6 +212,8 @@ class Action(db.Model):
data = db.Column(db.JSON, nullable=False) data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, nullable=True) default_fields = db.Column(db.JSON, nullable=True)
image_path = db.Column(db.String(255), nullable=True) image_path = db.Column(db.String(255), nullable=True)
is_favourite = db.Column(db.Boolean, default=False)
is_nsfw = db.Column(db.Boolean, default=False)
def __repr__(self): def __repr__(self):
return f'<Action {self.action_id}>' return f'<Action {self.action_id}>'
@@ -218,6 +227,8 @@ class Style(db.Model):
data = db.Column(db.JSON, nullable=False) data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, nullable=True) default_fields = db.Column(db.JSON, nullable=True)
image_path = db.Column(db.String(255), nullable=True) image_path = db.Column(db.String(255), nullable=True)
is_favourite = db.Column(db.Boolean, default=False)
is_nsfw = db.Column(db.Boolean, default=False)
def __repr__(self): def __repr__(self):
return f'<Style {self.style_id}>' return f'<Style {self.style_id}>'
@@ -231,6 +242,8 @@ class Scene(db.Model):
data = db.Column(db.JSON, nullable=False) data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, nullable=True) default_fields = db.Column(db.JSON, nullable=True)
image_path = db.Column(db.String(255), nullable=True) image_path = db.Column(db.String(255), nullable=True)
is_favourite = db.Column(db.Boolean, default=False)
is_nsfw = db.Column(db.Boolean, default=False)
def __repr__(self): def __repr__(self):
return f'<Scene {self.scene_id}>' return f'<Scene {self.scene_id}>'
@@ -244,6 +257,8 @@ class Detailer(db.Model):
data = db.Column(db.JSON, nullable=False) data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, nullable=True) default_fields = db.Column(db.JSON, nullable=True)
image_path = db.Column(db.String(255), nullable=True) image_path = db.Column(db.String(255), nullable=True)
is_favourite = db.Column(db.Boolean, default=False)
is_nsfw = db.Column(db.Boolean, default=False)
def __repr__(self): def __repr__(self):
return f'<Detailer {self.detailer_id}>' return f'<Detailer {self.detailer_id}>'
@@ -256,6 +271,8 @@ class Checkpoint(db.Model):
checkpoint_path = db.Column(db.String(255), nullable=False) # e.g. "Illustrious/model.safetensors" checkpoint_path = db.Column(db.String(255), nullable=False) # e.g. "Illustrious/model.safetensors"
data = db.Column(db.JSON, nullable=True) data = db.Column(db.JSON, nullable=True)
image_path = db.Column(db.String(255), nullable=True) image_path = db.Column(db.String(255), nullable=True)
is_favourite = db.Column(db.Boolean, default=False)
is_nsfw = db.Column(db.Boolean, default=False)
def __repr__(self): def __repr__(self):
return f'<Checkpoint {self.checkpoint_id}>' return f'<Checkpoint {self.checkpoint_id}>'

View File

@@ -18,6 +18,8 @@ def register_routes(app):
from routes import strengths from routes import strengths
from routes import transfer from routes import transfer
from routes import api from routes import api
from routes import regenerate
from routes import search
queue_api.register_routes(app) queue_api.register_routes(app)
settings.register_routes(app) settings.register_routes(app)
@@ -37,3 +39,5 @@ def register_routes(app):
strengths.register_routes(app) strengths.register_routes(app)
transfer.register_routes(app) transfer.register_routes(app)
api.register_routes(app) api.register_routes(app)
regenerate.register_routes(app)
search.register_routes(app)

View File

@@ -1,7 +1,6 @@
import json import json
import os import os
import re import re
import time
import random import random
import logging import logging
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Action, Outfit, Style, Scene, Detailer, Checkpoint, Settings, Look from models import db, Character, Action, Outfit, Style, Scene, Detailer, Checkpoint, Settings, Look
from services.workflow import _prepare_workflow, _get_default_checkpoint from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_actions from services.sync import sync_actions
from services.file_io import get_available_loras from services.file_io import get_available_loras
@@ -38,8 +37,17 @@ def register_routes(app):
@app.route('/actions') @app.route('/actions')
def actions_index(): def actions_index():
actions = Action.query.order_by(Action.name).all() query = Action.query
return render_template('actions/index.html', actions=actions) fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
actions = query.order_by(Action.is_favourite.desc(), Action.name).all()
return render_template('actions/index.html', actions=actions, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/actions/rescan', methods=['POST']) @app.route('/actions/rescan', methods=['POST'])
def rescan_actions(): def rescan_actions():
@@ -118,9 +126,15 @@ def register_routes(app):
else: else:
new_data.setdefault('lora', {}).pop(bound, None) new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list) # Suppress wardrobe toggle
tags_raw = request.form.get('tags', '') new_data['suppress_wardrobe'] = request.form.get('suppress_wardrobe') == 'on'
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
# Update Tags (structured dict)
new_data['tags'] = {
'participants': request.form.get('tag_participants', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
action.is_nsfw = new_data['tags']['nsfw']
action.data = new_data action.data = new_data
flag_modified(action, "data") flag_modified(action, "data")
@@ -201,6 +215,12 @@ def register_routes(app):
session[f'extra_neg_action_{slug}'] = extra_negative session[f'extra_neg_action_{slug}'] = extra_negative
session.modified = True session.modified = True
suppress_wardrobe = action_obj.data.get('suppress_wardrobe', False)
# Strip any wardrobe fields from manual selection when suppressed
if suppress_wardrobe:
selected_fields = [f for f in selected_fields if not f.startswith('wardrobe::')]
# Build combined data for prompt building # Build combined data for prompt building
if character: if character:
# Combine character identity/wardrobe with action details # Combine character identity/wardrobe with action details
@@ -232,16 +252,13 @@ def register_routes(app):
if 'lora' not in combined_data: combined_data['lora'] = {} if 'lora' not in combined_data: combined_data['lora'] = {}
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}" combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}"
# Merge tags
combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', [])))
# Use action's defaults if no manual selection # Use action's defaults if no manual selection
if not selected_fields: if not selected_fields:
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else [] selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
# Auto-include essential character fields if a character is selected # Auto-include essential character fields if a character is selected
if selected_fields: if selected_fields:
_ensure_character_fields(character, selected_fields) _ensure_character_fields(character, selected_fields, include_wardrobe=not suppress_wardrobe)
else: else:
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults) # Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression'] selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
@@ -249,7 +266,8 @@ def register_routes(app):
for key in ['base', 'head']: for key in ['base', 'head']:
if character.data.get('identity', {}).get(key): if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}') selected_fields.append(f'identity::{key}')
# Add wardrobe fields # Add wardrobe fields (unless suppressed)
if not suppress_wardrobe:
from utils import _WARDROBE_KEYS from utils import _WARDROBE_KEYS
wardrobe = character.get_active_wardrobe() wardrobe = character.get_active_wardrobe()
for key in _WARDROBE_KEYS: for key in _WARDROBE_KEYS:
@@ -281,7 +299,7 @@ def register_routes(app):
'tags': action_obj.data.get('tags', []) 'tags': action_obj.data.get('tags', [])
} }
if not selected_fields: if not selected_fields:
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags'] selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers']
default_fields = action_obj.default_fields default_fields = action_obj.default_fields
active_outfit = 'default' active_outfit = 'default'
@@ -322,7 +340,8 @@ def register_routes(app):
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ') val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
extra_parts.append(val) extra_parts.append(val)
# Wardrobe (active outfit) # Wardrobe (active outfit) — skip if suppressed
if not suppress_wardrobe:
from utils import _WARDROBE_KEYS from utils import _WARDROBE_KEYS
wardrobe = extra_char.get_active_wardrobe() wardrobe = extra_char.get_active_wardrobe()
for key in _WARDROBE_KEYS: for key in _WARDROBE_KEYS:
@@ -391,21 +410,27 @@ def register_routes(app):
actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/') actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/')
_lora_subfolder = os.path.basename(actions_lora_dir) _lora_subfolder = os.path.basename(actions_lora_dir)
if not os.path.exists(actions_lora_dir): if not os.path.exists(actions_lora_dir):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Actions LoRA directory not found.'}, 400
flash('Actions LoRA directory not found.', 'error') flash('Actions LoRA directory not found.', 'error')
return redirect(url_for('actions_index')) return redirect(url_for('actions_index'))
overwrite = request.form.get('overwrite') == 'true' overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('action_system.txt') system_prompt = load_prompt('action_system.txt')
if not system_prompt: if not system_prompt:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Action system prompt file not found.'}, 500
flash('Action system prompt file not found.', 'error') flash('Action system prompt file not found.', 'error')
return redirect(url_for('actions_index')) return redirect(url_for('actions_index'))
for filename in os.listdir(actions_lora_dir): job_ids = []
if filename.endswith('.safetensors'): skipped = 0
for filename in sorted(os.listdir(actions_lora_dir)):
if not filename.endswith('.safetensors'):
continue
name_base = filename.rsplit('.', 1)[0] name_base = filename.rsplit('.', 1)[0]
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
@@ -415,48 +440,46 @@ def register_routes(app):
is_existing = os.path.exists(json_path) is_existing = os.path.exists(json_path)
if is_existing and not overwrite: if is_existing and not overwrite:
skipped_count += 1 skipped += 1
continue continue
html_filename = f"{name_base}.html" # Read HTML companion file if it exists
html_path = os.path.join(actions_lora_dir, html_filename) html_path = os.path.join(actions_lora_dir, f"{name_base}.html")
html_content = "" html_content = ""
if os.path.exists(html_path): if os.path.exists(html_path):
try: try:
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf: with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
html_raw = hf.read() html_raw = hf.read()
# Strip HTML tags but keep text content for LLM context
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL) clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL) clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', clean_html) clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html) clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split()) html_content = ' '.join(clean_html.split())
except Exception as e: except Exception:
print(f"Error reading HTML {html_filename}: {e}") pass
try: def make_task(fn, aid, aname, jp, lsf, html_ctx, sys_prompt, is_exist):
print(f"Asking LLM to describe action: {action_name}") def task_fn(job):
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{filename}'" prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{fn}'"
if html_content: if html_ctx:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###" prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt) llm_response = call_llm(prompt, sys_prompt)
# Clean response
clean_json = llm_response.replace('```json', '').replace('```', '').strip() clean_json = llm_response.replace('```json', '').replace('```', '').strip()
action_data = json.loads(clean_json) action_data = json.loads(clean_json)
# Enforce system values while preserving LLM-extracted metadata # Enforce system values while preserving LLM-extracted metadata
action_data['action_id'] = action_id action_data['action_id'] = aid
action_data['action_name'] = action_name action_data['action_name'] = aname
# Update lora dict safely # Update lora dict safely
if 'lora' not in action_data: action_data['lora'] = {} if 'lora' not in action_data:
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" action_data['lora'] = {}
action_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
# Fallbacks if LLM failed to extract metadata # Fallbacks if LLM failed to extract metadata
if not action_data['lora'].get('lora_triggers'): if not action_data['lora'].get('lora_triggers'):
action_data['lora']['lora_triggers'] = name_base action_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
if action_data['lora'].get('lora_weight') is None: if action_data['lora'].get('lora_weight') is None:
action_data['lora']['lora_weight'] = 1.0 action_data['lora']['lora_weight'] = 1.0
if action_data['lora'].get('lora_weight_min') is None: if action_data['lora'].get('lora_weight_min') is None:
@@ -464,39 +487,45 @@ def register_routes(app):
if action_data['lora'].get('lora_weight_max') is None: if action_data['lora'].get('lora_weight_max') is None:
action_data['lora']['lora_weight_max'] = 1.0 action_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f: os.makedirs(os.path.dirname(jp), exist_ok=True)
with open(jp, 'w') as f:
json.dump(action_data, f, indent=2) json.dump(action_data, f, indent=2)
if is_existing: job['result'] = {'name': aname, 'action': 'overwritten' if is_exist else 'created'}
overwritten_count += 1 return task_fn
else:
created_count += 1
# Small delay to avoid API rate limits if many files job = _enqueue_task(
time.sleep(0.5) f"Create action: {action_name}",
make_task(filename, action_id, action_name, json_path,
_lora_subfolder, html_content, system_prompt, is_existing)
)
job_ids.append(job['id'])
except Exception as e: # Enqueue a sync task to run after all creates
print(f"Error creating action for {filename}: {e}") if job_ids:
def sync_task(job):
if created_count > 0 or overwritten_count > 0:
sync_actions() sync_actions()
msg = f'Successfully processed actions: {created_count} created, {overwritten_count} overwritten.' job['result'] = {'synced': True}
if skipped_count > 0: _enqueue_task("Sync actions DB", sync_task)
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No actions created or overwritten. {skipped_count} existing actions found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} action creation tasks ({skipped} skipped). Watch progress in the queue.')
return redirect(url_for('actions_index')) return redirect(url_for('actions_index'))
@app.route('/action/create', methods=['GET', 'POST']) @app.route('/action/create', methods=['GET', 'POST'])
def create_action(): def create_action():
form_data = {}
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name') name = request.form.get('name')
slug = request.form.get('filename', '').strip() slug = request.form.get('filename', '').strip()
prompt = request.form.get('prompt', '') prompt = request.form.get('prompt', '')
use_llm = request.form.get('use_llm') == 'on' use_llm = request.form.get('use_llm') == 'on'
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
if not slug: if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -513,12 +542,12 @@ def register_routes(app):
if use_llm: if use_llm:
if not prompt: if not prompt:
flash("Description is required when AI generation is enabled.") flash("Description is required when AI generation is enabled.")
return redirect(request.url) return render_template('actions/create.html', form_data=form_data)
system_prompt = load_prompt('action_system.txt') system_prompt = load_prompt('action_system.txt')
if not system_prompt: if not system_prompt:
flash("Action system prompt file not found.") flash("Action system prompt file not found.")
return redirect(request.url) return render_template('actions/create.html', form_data=form_data)
try: try:
llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt) llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt)
@@ -529,7 +558,7 @@ def register_routes(app):
except Exception as e: except Exception as e:
print(f"LLM error: {e}") print(f"LLM error: {e}")
flash(f"Failed to generate action profile: {e}") flash(f"Failed to generate action profile: {e}")
return redirect(request.url) return render_template('actions/create.html', form_data=form_data)
else: else:
action_data = { action_data = {
"action_id": safe_slug, "action_id": safe_slug,
@@ -538,6 +567,7 @@ def register_routes(app):
"base": "", "head": "", "upper_body": "", "lower_body": "", "base": "", "head": "", "upper_body": "", "lower_body": "",
"hands": "", "feet": "", "additional": "" "hands": "", "feet": "", "additional": ""
}, },
"suppress_wardrobe": False,
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""}, "lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
"tags": [] "tags": []
} }
@@ -559,9 +589,9 @@ def register_routes(app):
except Exception as e: except Exception as e:
print(f"Save error: {e}") print(f"Save error: {e}")
flash(f"Failed to create action: {e}") flash(f"Failed to create action: {e}")
return redirect(request.url) return render_template('actions/create.html', form_data=form_data)
return render_template('actions/create.html') return render_template('actions/create.html', form_data=form_data)
@app.route('/action/<path:slug>/clone', methods=['POST']) @app.route('/action/<path:slug>/clone', methods=['POST'])
def clone_action(slug): def clone_action(slug):
@@ -619,3 +649,12 @@ def register_routes(app):
with open(file_path, 'w') as f: with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2) json.dump(new_data, f, indent=2)
return {'success': True} return {'success': True}
@app.route('/action/<path:slug>/favourite', methods=['POST'])
def toggle_action_favourite(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
action.is_favourite = not action.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': action.is_favourite}
return redirect(url_for('action_detail', slug=slug))

View File

@@ -10,7 +10,7 @@ from werkzeug.utils import secure_filename
from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
from services.file_io import get_available_loras from services.file_io import get_available_loras
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize
from services.llm import call_llm, load_prompt from services.llm import call_character_mcp_tool, call_llm, load_prompt
from services.prompts import build_prompt from services.prompts import build_prompt
from services.sync import sync_characters from services.sync import sync_characters
from services.workflow import _get_default_checkpoint, _prepare_workflow from services.workflow import _get_default_checkpoint, _prepare_workflow
@@ -23,8 +23,17 @@ def register_routes(app):
@app.route('/') @app.route('/')
def index(): def index():
characters = Character.query.order_by(Character.name).all() query = Character.query
return render_template('index.html', characters=characters) fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
characters = query.order_by(Character.is_favourite.desc(), Character.name).all()
return render_template('index.html', characters=characters, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/rescan', methods=['POST']) @app.route('/rescan', methods=['POST'])
def rescan(): def rescan():
@@ -219,6 +228,7 @@ def register_routes(app):
name = request.form.get('name') name = request.form.get('name')
slug = request.form.get('filename', '').strip() slug = request.form.get('filename', '').strip()
prompt = request.form.get('prompt', '') prompt = request.form.get('prompt', '')
wiki_url = request.form.get('wiki_url', '').strip()
use_llm = request.form.get('use_llm') == 'on' use_llm = request.form.get('use_llm') == 'on'
outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none' outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none'
existing_outfit_id = request.form.get('existing_outfit_id') existing_outfit_id = request.form.get('existing_outfit_id')
@@ -228,6 +238,7 @@ def register_routes(app):
'name': name, 'name': name,
'filename': slug, 'filename': slug,
'prompt': prompt, 'prompt': prompt,
'wiki_url': wiki_url,
'use_llm': use_llm, 'use_llm': use_llm,
'outfit_mode': outfit_mode, 'outfit_mode': outfit_mode,
'existing_outfit_id': existing_outfit_id 'existing_outfit_id': existing_outfit_id
@@ -261,6 +272,20 @@ def register_routes(app):
flash(error_msg) flash(error_msg)
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
# Fetch reference data from wiki URL if provided
wiki_reference = ''
if wiki_url:
logger.info(f"Fetching character data from URL: {wiki_url}")
wiki_data = call_character_mcp_tool('get_character_from_url', {
'url': wiki_url,
'name': name,
})
if wiki_data:
wiki_reference = f"\n\nReference data from wiki:\n{wiki_data}\n\nUse this reference to accurately describe the character's appearance, outfit, and features."
logger.info(f"Got wiki reference data ({len(wiki_data)} chars)")
else:
logger.warning(f"Failed to fetch wiki data from {wiki_url}")
# Step 1: Generate or select outfit first # Step 1: Generate or select outfit first
default_outfit_id = 'default' default_outfit_id = 'default'
generated_outfit = None generated_outfit = None
@@ -271,7 +296,7 @@ def register_routes(app):
outfit_name = f"{name} - default" outfit_name = f"{name} - default"
outfit_prompt = f"""Generate an outfit for character "{name}". outfit_prompt = f"""Generate an outfit for character "{name}".
The character is described as: {prompt} The character is described as: {prompt}{wiki_reference}
Create an outfit JSON with wardrobe fields appropriate for this character.""" Create an outfit JSON with wardrobe fields appropriate for this character."""
@@ -344,7 +369,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
# Step 2: Generate character (without wardrobe section) # Step 2: Generate character (without wardrobe section)
char_prompt = f"""Generate a character named "{name}". char_prompt = f"""Generate a character named "{name}".
Description: {prompt} Description: {prompt}{wiki_reference}
Default Outfit: {default_outfit_id} Default Outfit: {default_outfit_id}
@@ -516,9 +541,13 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
if form_key in request.form: if form_key in request.form:
new_data['wardrobe'][key] = request.form.get(form_key) new_data['wardrobe'][key] = request.form.get(form_key)
# Update Tags (comma separated string to list) # Update structured tags
tags_raw = request.form.get('tags', '') new_data['tags'] = {
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t] 'origin_series': request.form.get('tag_origin_series', '').strip(),
'origin_type': request.form.get('tag_origin_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
character.is_nsfw = new_data['tags']['nsfw']
character.data = new_data character.data = new_data
flag_modified(character, "data") flag_modified(character, "data")
@@ -867,3 +896,12 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
db.session.commit() db.session.commit()
flash('Default prompt selection saved for this character!') flash('Default prompt selection saved for this character!')
return redirect(url_for('detail', slug=slug)) return redirect(url_for('detail', slug=slug))
@app.route('/character/<path:slug>/favourite', methods=['POST'])
def toggle_character_favourite(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
character.is_favourite = not character.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': character.is_favourite}
return redirect(url_for('detail', slug=slug))

View File

@@ -1,7 +1,6 @@
import json import json
import os import os
import re import re
import time
import logging import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Checkpoint, Character, Settings from models import db, Checkpoint, Character, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts from services.workflow import _prepare_workflow, _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_checkpoints, _default_checkpoint_data from services.sync import sync_checkpoints, _default_checkpoint_data
from services.file_io import get_available_checkpoints from services.file_io import get_available_checkpoints
@@ -57,8 +56,17 @@ def register_routes(app):
@app.route('/checkpoints') @app.route('/checkpoints')
def checkpoints_index(): def checkpoints_index():
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all() query = Checkpoint.query
return render_template('checkpoints/index.html', checkpoints=checkpoints) fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
checkpoints = query.order_by(Checkpoint.is_favourite.desc(), Checkpoint.name).all()
return render_template('checkpoints/index.html', checkpoints=checkpoints, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/checkpoints/rescan', methods=['POST']) @app.route('/checkpoints/rescan', methods=['POST'])
def rescan_checkpoints(): def rescan_checkpoints():
@@ -189,9 +197,9 @@ def register_routes(app):
os.makedirs(checkpoints_dir, exist_ok=True) os.makedirs(checkpoints_dir, exist_ok=True)
overwrite = request.form.get('overwrite') == 'true' overwrite = request.form.get('overwrite') == 'true'
created_count = 0 skipped = 0
skipped_count = 0 written_directly = 0
overwritten_count = 0 job_ids = []
system_prompt = load_prompt('checkpoint_system.txt') system_prompt = load_prompt('checkpoint_system.txt')
if not system_prompt: if not system_prompt:
@@ -219,7 +227,7 @@ def register_routes(app):
is_existing = os.path.exists(json_path) is_existing = os.path.exists(json_path)
if is_existing and not overwrite: if is_existing and not overwrite:
skipped_count += 1 skipped += 1
continue continue
# Look for a matching HTML file alongside the model file # Look for a matching HTML file alongside the model file
@@ -235,52 +243,72 @@ def register_routes(app):
clean_html = re.sub(r'<[^>]+>', ' ', clean_html) clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split()) html_content = ' '.join(clean_html.split())
except Exception as e: except Exception as e:
print(f"Error reading HTML for {filename}: {e}") logger.error("Error reading HTML for %s: %s", filename, e)
defaults = _default_checkpoint_data(checkpoint_path, filename) defaults = _default_checkpoint_data(checkpoint_path, filename)
if html_content: if html_content:
try: # Has HTML companion — enqueue LLM task
print(f"Asking LLM to describe checkpoint: {filename}") def make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing):
def task_fn(job):
prompt = ( prompt = (
f"Generate checkpoint metadata JSON for the model file: '{filename}' " f"Generate checkpoint metadata JSON for the model file: '{filename}' "
f"(checkpoint_path: '{checkpoint_path}').\n\n" f"(checkpoint_path: '{checkpoint_path}').\n\n"
f"Here is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###" f"Here is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
) )
try:
llm_response = call_llm(prompt, system_prompt) llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip() clean_json = llm_response.replace('```json', '').replace('```', '').strip()
ckpt_data = json.loads(clean_json) ckpt_data = json.loads(clean_json)
# Enforce fixed fields
ckpt_data['checkpoint_path'] = checkpoint_path ckpt_data['checkpoint_path'] = checkpoint_path
ckpt_data['checkpoint_name'] = filename ckpt_data['checkpoint_name'] = filename
# Fill missing fields with defaults
for key, val in defaults.items(): for key, val in defaults.items():
if key not in ckpt_data or ckpt_data[key] is None: if key not in ckpt_data or ckpt_data[key] is None:
ckpt_data[key] = val ckpt_data[key] = val
time.sleep(0.5)
except Exception as e: except Exception as e:
print(f"LLM error for {filename}: {e}. Using defaults.") logger.error("LLM error for %s: %s. Using defaults.", filename, e)
ckpt_data = defaults
else:
ckpt_data = defaults ckpt_data = defaults
try:
with open(json_path, 'w') as f: with open(json_path, 'w') as f:
json.dump(ckpt_data, f, indent=2) json.dump(ckpt_data, f, indent=2)
if is_existing:
overwritten_count += 1 job['result'] = {'name': filename, 'action': 'overwritten' if is_existing else 'created'}
return task_fn
job = _enqueue_task(f"Create checkpoint: {filename}", make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing))
job_ids.append(job['id'])
else: else:
created_count += 1 # No HTML — write defaults directly (no LLM needed)
try:
with open(json_path, 'w') as f:
json.dump(defaults, f, indent=2)
written_directly += 1
except Exception as e: except Exception as e:
print(f"Error saving JSON for {filename}: {e}") logger.error("Error saving JSON for %s: %s", filename, e)
if created_count > 0 or overwritten_count > 0: needs_sync = len(job_ids) > 0 or written_directly > 0
if needs_sync:
if job_ids:
# Sync after all LLM tasks complete
def sync_task(job):
sync_checkpoints() sync_checkpoints()
msg = f'Successfully processed checkpoints: {created_count} created, {overwritten_count} overwritten.' job['result'] = {'synced': True}
if skipped_count > 0: _enqueue_task("Sync checkpoints DB", sync_task)
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else: else:
flash(f'No checkpoints created or overwritten. {skipped_count} existing entries found.') # No LLM tasks — sync immediately
sync_checkpoints()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'written_directly': written_directly, 'skipped': skipped}
flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).')
return redirect(url_for('checkpoints_index')) return redirect(url_for('checkpoints_index'))
@app.route('/checkpoint/<path:slug>/favourite', methods=['POST'])
def toggle_checkpoint_favourite(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
ckpt.is_favourite = not ckpt.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': ckpt.is_favourite}
return redirect(url_for('checkpoint_detail', slug=slug))

View File

@@ -1,7 +1,6 @@
import json import json
import os import os
import re import re
import time
import logging import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look
from services.workflow import _prepare_workflow, _get_default_checkpoint from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_detailers from services.sync import sync_detailers
from services.file_io import get_available_loras from services.file_io import get_available_loras
@@ -27,11 +26,8 @@ def register_routes(app):
combined_data = character.data.copy() combined_data = character.data.copy()
combined_data['character_id'] = character.character_id combined_data['character_id'] = character.character_id
# Merge detailer prompt into character's tags # Capture detailer prompt for injection into main prompt later
detailer_prompt = detailer_obj.data.get('prompt', '') detailer_prompt = detailer_obj.data.get('prompt', '')
if detailer_prompt:
if 'tags' not in combined_data: combined_data['tags'] = []
combined_data['tags'].append(detailer_prompt)
# Merge detailer lora triggers if present # Merge detailer lora triggers if present
detailer_lora = detailer_obj.data.get('lora', {}) detailer_lora = detailer_obj.data.get('lora', {})
@@ -53,21 +49,19 @@ def register_routes(app):
for key in _WARDROBE_KEYS: for key in _WARDROBE_KEYS:
if wardrobe.get(key): if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}') selected_fields.append(f'wardrobe::{key}')
selected_fields.extend(['special::tags', 'lora::lora_triggers']) selected_fields.extend(['lora::lora_triggers'])
default_fields = detailer_obj.default_fields default_fields = detailer_obj.default_fields
active_outfit = character.active_outfit active_outfit = character.active_outfit
else: else:
# Detailer only - no character # Detailer only - no character
detailer_prompt = detailer_obj.data.get('prompt', '') detailer_prompt = detailer_obj.data.get('prompt', '')
detailer_tags = [detailer_prompt] if detailer_prompt else []
combined_data = { combined_data = {
'character_id': detailer_obj.detailer_id, 'character_id': detailer_obj.detailer_id,
'tags': detailer_tags,
'lora': detailer_obj.data.get('lora', {}), 'lora': detailer_obj.data.get('lora', {}),
} }
if not selected_fields: if not selected_fields:
selected_fields = ['special::tags', 'lora::lora_triggers'] selected_fields = ['lora::lora_triggers']
default_fields = detailer_obj.default_fields default_fields = detailer_obj.default_fields
active_outfit = 'default' active_outfit = 'default'
@@ -76,6 +70,11 @@ def register_routes(app):
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
# Inject detailer prompt directly into main prompt
if detailer_prompt:
prompt_str = detailer_prompt if isinstance(detailer_prompt, str) else ', '.join(detailer_prompt)
prompts['main'] = f"{prompts['main']}, {prompt_str}" if prompts['main'] else prompt_str
_append_background(prompts, character) _append_background(prompts, character)
if extra_positive: if extra_positive:
@@ -87,8 +86,17 @@ def register_routes(app):
@app.route('/detailers') @app.route('/detailers')
def detailers_index(): def detailers_index():
detailers = Detailer.query.order_by(Detailer.name).all() query = Detailer.query
return render_template('detailers/index.html', detailers=detailers) fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
detailers = query.order_by(Detailer.is_favourite.desc(), Detailer.name).all()
return render_template('detailers/index.html', detailers=detailers, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/detailers/rescan', methods=['POST']) @app.route('/detailers/rescan', methods=['POST'])
def rescan_detailers(): def rescan_detailers():
@@ -162,9 +170,13 @@ def register_routes(app):
else: else:
new_data.setdefault('lora', {}).pop(bound, None) new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list) # Update structured tags
tags_raw = request.form.get('tags', '') new_data['tags'] = {
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()] 'associated_resource': request.form.get('tag_associated_resource', '').strip(),
'adetailer_targets': request.form.getlist('tag_adetailer_targets'),
'nsfw': 'tag_nsfw' in request.form,
}
detailer.is_nsfw = new_data['tags']['nsfw']
detailer.data = new_data detailer.data = new_data
flag_modified(detailer, "data") flag_modified(detailer, "data")
@@ -318,15 +330,16 @@ def register_routes(app):
return redirect(url_for('detailers_index')) return redirect(url_for('detailers_index'))
overwrite = request.form.get('overwrite') == 'true' overwrite = request.form.get('overwrite') == 'true'
created_count = 0 skipped = 0
skipped_count = 0 job_ids = []
overwritten_count = 0
system_prompt = load_prompt('detailer_system.txt') system_prompt = load_prompt('detailer_system.txt')
if not system_prompt: if not system_prompt:
flash('Detailer system prompt file not found.', 'error') flash('Detailer system prompt file not found.', 'error')
return redirect(url_for('detailers_index')) return redirect(url_for('detailers_index'))
detailers_dir = app.config['DETAILERS_DIR']
for filename in os.listdir(detailers_lora_dir): for filename in os.listdir(detailers_lora_dir):
if filename.endswith('.safetensors'): if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0] name_base = filename.rsplit('.', 1)[0]
@@ -334,11 +347,11 @@ def register_routes(app):
detailer_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() detailer_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{detailer_id}.json" json_filename = f"{detailer_id}.json"
json_path = os.path.join(app.config['DETAILERS_DIR'], json_filename) json_path = os.path.join(detailers_dir, json_filename)
is_existing = os.path.exists(json_path) is_existing = os.path.exists(json_path)
if is_existing and not overwrite: if is_existing and not overwrite:
skipped_count += 1 skipped += 1
continue continue
html_filename = f"{name_base}.html" html_filename = f"{name_base}.html"
@@ -354,10 +367,10 @@ def register_routes(app):
clean_html = re.sub(r'<[^>]+>', ' ', clean_html) clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split()) html_content = ' '.join(clean_html.split())
except Exception as e: except Exception as e:
print(f"Error reading HTML {html_filename}: {e}") logger.error("Error reading HTML %s: %s", html_filename, e)
try: def make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
print(f"Asking LLM to describe detailer: {detailer_name}") def task_fn(job):
prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'" prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'"
if html_content: if html_content:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###" prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
@@ -384,33 +397,33 @@ def register_routes(app):
with open(json_path, 'w') as f: with open(json_path, 'w') as f:
json.dump(detailer_data, f, indent=2) json.dump(detailer_data, f, indent=2)
if is_existing: job['result'] = {'name': detailer_name, 'action': 'overwritten' if is_existing else 'created'}
overwritten_count += 1 return task_fn
else:
created_count += 1
# Small delay to avoid API rate limits if many files job = _enqueue_task(f"Create detailer: {detailer_name}", make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
time.sleep(0.5) job_ids.append(job['id'])
except Exception as e:
print(f"Error creating detailer for {filename}: {e}")
if created_count > 0 or overwritten_count > 0: if job_ids:
def sync_task(job):
sync_detailers() sync_detailers()
msg = f'Successfully processed detailers: {created_count} created, {overwritten_count} overwritten.' job['result'] = {'synced': True}
if skipped_count > 0: _enqueue_task("Sync detailers DB", sync_task)
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No new detailers created or overwritten. {skipped_count} existing detailers found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} detailer tasks ({skipped} skipped).')
return redirect(url_for('detailers_index')) return redirect(url_for('detailers_index'))
@app.route('/detailer/create', methods=['GET', 'POST']) @app.route('/detailer/create', methods=['GET', 'POST'])
def create_detailer(): def create_detailer():
form_data = {}
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name') name = request.form.get('name')
slug = request.form.get('filename', '').strip() slug = request.form.get('filename', '').strip()
form_data = {'name': name, 'filename': slug}
if not slug: if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -452,6 +465,15 @@ def register_routes(app):
except Exception as e: except Exception as e:
print(f"Save error: {e}") print(f"Save error: {e}")
flash(f"Failed to create detailer: {e}") flash(f"Failed to create detailer: {e}")
return redirect(request.url) return render_template('detailers/create.html', form_data=form_data)
return render_template('detailers/create.html') return render_template('detailers/create.html', form_data=form_data)
@app.route('/detailer/<path:slug>/favourite', methods=['POST'])
def toggle_detailer_favourite(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
detailer.is_favourite = not detailer.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': detailer.is_favourite}
return redirect(url_for('detailer_detail', slug=slug))

View File

@@ -4,13 +4,13 @@ import logging
from flask import render_template, request, current_app from flask import render_template, request, current_app
from models import ( from models import (
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint, db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint, Preset,
) )
logger = logging.getLogger('gaze') logger = logging.getLogger('gaze')
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints'] GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints', 'looks', 'presets', 'generator']
_MODEL_MAP = { _MODEL_MAP = {
'characters': Character, 'characters': Character,
@@ -20,11 +20,36 @@ _MODEL_MAP = {
'styles': Style, 'styles': Style,
'detailers': Detailer, 'detailers': Detailer,
'checkpoints': Checkpoint, 'checkpoints': Checkpoint,
'looks': Look,
'presets': Preset,
'generator': Preset,
}
# Maps xref_category param names to sidecar JSON keys
_XREF_KEY_MAP = {
'character': 'character_slug',
'outfit': 'outfit_slug',
'action': 'action_slug',
'style': 'style_slug',
'scene': 'scene_slug',
'detailer': 'detailer_slug',
'look': 'look_slug',
'preset': 'preset_slug',
} }
def register_routes(app): def register_routes(app):
def _read_sidecar(upload_folder, image_path):
"""Read JSON sidecar for an image. Returns dict or None."""
sidecar = image_path.rsplit('.', 1)[0] + '.json'
sidecar_path = os.path.join(upload_folder, sidecar)
try:
with open(sidecar_path) as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def _scan_gallery_images(category_filter='all', slug_filter=''): def _scan_gallery_images(category_filter='all', slug_filter=''):
"""Return sorted list of image dicts from the uploads directory.""" """Return sorted list of image dicts from the uploads directory."""
upload_folder = app.config['UPLOAD_FOLDER'] upload_folder = app.config['UPLOAD_FOLDER']
@@ -164,18 +189,48 @@ def register_routes(app):
category = request.args.get('category', 'all') category = request.args.get('category', 'all')
slug = request.args.get('slug', '') slug = request.args.get('slug', '')
sort = request.args.get('sort', 'newest') sort = request.args.get('sort', 'newest')
xref_category = request.args.get('xref_category', '')
xref_slug = request.args.get('xref_slug', '')
favourite_filter = request.args.get('favourite', '')
nsfw_filter = request.args.get('nsfw', 'all')
page = max(1, int(request.args.get('page', 1))) page = max(1, int(request.args.get('page', 1)))
per_page = int(request.args.get('per_page', 48)) per_page = int(request.args.get('per_page', 48))
per_page = per_page if per_page in (24, 48, 96) else 48 per_page = per_page if per_page in (24, 48, 96) else 48
images = _scan_gallery_images(category, slug) images = _scan_gallery_images(category, slug)
# Read sidecar data for filtering (favourite/NSFW/xref)
upload_folder = app.config['UPLOAD_FOLDER']
need_sidecar = (xref_category and xref_slug) or favourite_filter or nsfw_filter != 'all'
if need_sidecar:
for img in images:
img['_sidecar'] = _read_sidecar(upload_folder, img['path']) or {}
# Cross-reference filter
if xref_category and xref_slug and xref_category in _XREF_KEY_MAP:
sidecar_key = _XREF_KEY_MAP[xref_category]
images = [img for img in images if img.get('_sidecar', {}).get(sidecar_key) == xref_slug]
# Favourite filter
if favourite_filter == 'on':
images = [img for img in images if img.get('_sidecar', {}).get('is_favourite')]
# NSFW filter
if nsfw_filter == 'sfw':
images = [img for img in images if not img.get('_sidecar', {}).get('is_nsfw')]
elif nsfw_filter == 'nsfw':
images = [img for img in images if img.get('_sidecar', {}).get('is_nsfw')]
if sort == 'oldest': if sort == 'oldest':
images.reverse() images.reverse()
elif sort == 'random': elif sort == 'random':
import random import random
random.shuffle(images) random.shuffle(images)
# Sort favourites first when favourite filter not active but sort is newest/oldest
if sort in ('newest', 'oldest') and not favourite_filter and need_sidecar:
images.sort(key=lambda x: (not x.get('_sidecar', {}).get('is_favourite', False), images.index(x)))
total = len(images) total = len(images)
total_pages = max(1, (total + per_page - 1) // per_page) total_pages = max(1, (total + per_page - 1) // per_page)
page = min(page, total_pages) page = min(page, total_pages)
@@ -197,6 +252,11 @@ def register_routes(app):
if Model: if Model:
slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()] slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()]
# Attach sidecar data to page images for template use
for img in page_images:
if '_sidecar' not in img:
img['_sidecar'] = _read_sidecar(os.path.abspath(app.config['UPLOAD_FOLDER']), img['path']) or {}
return render_template( return render_template(
'gallery.html', 'gallery.html',
images=page_images, images=page_images,
@@ -209,6 +269,10 @@ def register_routes(app):
sort=sort, sort=sort,
categories=GALLERY_CATEGORIES, categories=GALLERY_CATEGORIES,
slug_options=slug_options, slug_options=slug_options,
xref_category=xref_category,
xref_slug=xref_slug,
favourite_filter=favourite_filter,
nsfw_filter=nsfw_filter,
) )
@app.route('/gallery/prompt-data') @app.route('/gallery/prompt-data')
@@ -228,8 +292,60 @@ def register_routes(app):
meta = _parse_comfy_png_metadata(abs_img) meta = _parse_comfy_png_metadata(abs_img)
meta['path'] = img_path meta['path'] = img_path
# Include sidecar data if available (for cross-reference links)
sidecar = _read_sidecar(upload_folder, img_path)
if sidecar:
meta['sidecar'] = sidecar
return meta return meta
def _write_sidecar(upload_folder, image_path, data):
"""Write/update JSON sidecar for an image."""
sidecar = image_path.rsplit('.', 1)[0] + '.json'
sidecar_path = os.path.join(upload_folder, sidecar)
existing = {}
try:
with open(sidecar_path) as f:
existing = json.load(f)
except (OSError, json.JSONDecodeError):
pass
existing.update(data)
with open(sidecar_path, 'w') as f:
json.dump(existing, f, indent=2)
@app.route('/gallery/image/favourite', methods=['POST'])
def gallery_image_favourite():
"""Toggle favourite on a gallery image via sidecar JSON."""
data = request.get_json(silent=True) or {}
img_path = data.get('path', '')
if not img_path:
return {'error': 'path required'}, 400
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
if not abs_img.startswith(upload_folder + os.sep) or not os.path.isfile(abs_img):
return {'error': 'Invalid path'}, 400
sidecar = _read_sidecar(upload_folder, img_path) or {}
new_val = not sidecar.get('is_favourite', False)
_write_sidecar(upload_folder, img_path, {'is_favourite': new_val})
return {'success': True, 'is_favourite': new_val}
@app.route('/gallery/image/nsfw', methods=['POST'])
def gallery_image_nsfw():
"""Toggle NSFW on a gallery image via sidecar JSON."""
data = request.get_json(silent=True) or {}
img_path = data.get('path', '')
if not img_path:
return {'error': 'path required'}, 400
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
if not abs_img.startswith(upload_folder + os.sep) or not os.path.isfile(abs_img):
return {'error': 'Invalid path'}, 400
sidecar = _read_sidecar(upload_folder, img_path) or {}
new_val = not sidecar.get('is_nsfw', False)
_write_sidecar(upload_folder, img_path, {'is_nsfw': new_val})
return {'success': True, 'is_nsfw': new_val}
@app.route('/gallery/delete', methods=['POST']) @app.route('/gallery/delete', methods=['POST'])
def gallery_delete(): def gallery_delete():
"""Delete a generated image from the gallery. Only the image file is removed.""" """Delete a generated image from the gallery. Only the image file is removed."""
@@ -249,6 +365,10 @@ def register_routes(app):
if os.path.isfile(abs_img): if os.path.isfile(abs_img):
os.remove(abs_img) os.remove(abs_img)
# Also remove sidecar JSON if present
sidecar = abs_img.rsplit('.', 1)[0] + '.json'
if os.path.isfile(sidecar):
os.remove(sidecar)
return {'status': 'ok'} return {'status': 'ok'}
@@ -260,6 +380,7 @@ def register_routes(app):
hard: removes JSON data file + LoRA/checkpoint safetensors + DB record. hard: removes JSON data file + LoRA/checkpoint safetensors + DB record.
""" """
_RESOURCE_MODEL_MAP = { _RESOURCE_MODEL_MAP = {
'characters': Character,
'looks': Look, 'looks': Look,
'styles': Style, 'styles': Style,
'actions': Action, 'actions': Action,
@@ -269,6 +390,7 @@ def register_routes(app):
'checkpoints': Checkpoint, 'checkpoints': Checkpoint,
} }
_RESOURCE_DATA_DIRS = { _RESOURCE_DATA_DIRS = {
'characters': app.config['CHARACTERS_DIR'],
'looks': app.config['LOOKS_DIR'], 'looks': app.config['LOOKS_DIR'],
'styles': app.config['STYLES_DIR'], 'styles': app.config['STYLES_DIR'],
'actions': app.config['ACTIONS_DIR'], 'actions': app.config['ACTIONS_DIR'],

View File

@@ -1,154 +1,135 @@
import json
import logging import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint from flask import render_template, request, redirect, url_for, flash
from services.prompts import build_prompt, build_extras_prompt from models import Preset
from services.workflow import _prepare_workflow, _get_default_checkpoint from services.generation import generate_from_preset
from services.job_queue import _enqueue_job, _make_finalize
from services.file_io import get_available_checkpoints from services.file_io import get_available_checkpoints
from services.comfyui import get_loaded_checkpoint from services.comfyui import get_loaded_checkpoint
from services.workflow import _get_default_checkpoint
from services.sync import _resolve_preset_entity
logger = logging.getLogger('gaze') logger = logging.getLogger('gaze')
def register_routes(app): def register_routes(app):
@app.route('/generator', methods=['GET', 'POST']) @app.route('/generator', methods=['GET'])
def generator(): def generator():
characters = Character.query.order_by(Character.name).all() presets = Preset.query.order_by(Preset.name).all()
checkpoints = get_available_checkpoints() checkpoints = get_available_checkpoints()
actions = Action.query.order_by(Action.name).all()
outfits = Outfit.query.order_by(Outfit.name).all()
scenes = Scene.query.order_by(Scene.name).all()
styles = Style.query.order_by(Style.name).all()
detailers = Detailer.query.order_by(Detailer.name).all()
if not checkpoints:
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
# Default to whatever is currently loaded in ComfyUI, then settings default
selected_ckpt = get_loaded_checkpoint() selected_ckpt = get_loaded_checkpoint()
if not selected_ckpt: if not selected_ckpt:
default_path, _ = _get_default_checkpoint() default_path, _ = _get_default_checkpoint()
selected_ckpt = default_path selected_ckpt = default_path
if request.method == 'POST': # Pre-select preset from query param
char_slug = request.form.get('character') preset_slug = request.args.get('preset', '')
checkpoint = request.form.get('checkpoint')
custom_positive = request.form.get('positive_prompt', '')
custom_negative = request.form.get('negative_prompt', '')
action_slugs = request.form.getlist('action_slugs') return render_template('generator.html',
outfit_slugs = request.form.getlist('outfit_slugs') presets=presets,
scene_slugs = request.form.getlist('scene_slugs') checkpoints=checkpoints,
style_slugs = request.form.getlist('style_slugs') selected_ckpt=selected_ckpt,
detailer_slugs = request.form.getlist('detailer_slugs') preset_slug=preset_slug)
override_prompt = request.form.get('override_prompt', '').strip()
width = request.form.get('width') or 1024
height = request.form.get('height') or 1024
character = Character.query.filter_by(slug=char_slug).first_or_404() @app.route('/generator/generate', methods=['POST'])
def generator_generate():
preset_slug = request.form.get('preset_slug', '').strip()
if not preset_slug:
return {'error': 'No preset selected'}, 400
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else [] preset = Preset.query.filter_by(slug=preset_slug).first()
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else [] if not preset:
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else [] return {'error': 'Preset not found'}, 404
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
try: try:
with open('comfy_workflow.json', 'r') as f: overrides = {
workflow = json.load(f) 'checkpoint': request.form.get('checkpoint', '').strip() or None,
'extra_positive': request.form.get('extra_positive', '').strip(),
'extra_negative': request.form.get('extra_negative', '').strip(),
'action': 'preview',
}
# Build base prompts from character defaults
prompts = build_prompt(character.data, default_fields=character.default_fields)
if override_prompt:
prompts["main"] = override_prompt
else:
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
combined = prompts["main"]
if extras:
combined = f"{combined}, {extras}"
if custom_positive:
combined = f"{custom_positive}, {combined}"
prompts["main"] = combined
# Apply face/hand prompt overrides if provided
override_face = request.form.get('override_face_prompt', '').strip()
override_hand = request.form.get('override_hand_prompt', '').strip()
if override_face:
prompts["face"] = override_face
if override_hand:
prompts["hand"] = override_hand
# Parse optional seed
seed_val = request.form.get('seed', '').strip() seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None if seed_val:
overrides['seed'] = int(seed_val)
# Prepare workflow - first selected item per category supplies its LoRA slot width = request.form.get('width', '').strip()
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint).first() if checkpoint else None height = request.form.get('height', '').strip()
workflow = _prepare_workflow( if width and height:
workflow, character, prompts, checkpoint, custom_negative, overrides['width'] = int(width)
outfit=sel_outfits[0] if sel_outfits else None, overrides['height'] = int(height)
action=sel_actions[0] if sel_actions else None,
style=sel_styles[0] if sel_styles else None,
detailer=sel_detailers[0] if sel_detailers else None,
scene=sel_scenes[0] if sel_scenes else None,
width=width,
height=height,
checkpoint_data=ckpt_obj.data if ckpt_obj else None,
fixed_seed=fixed_seed,
)
print(f"Queueing generator prompt for {character.character_id}") job = generate_from_preset(preset, overrides, save_category='generator')
_finalize = _make_finalize('characters', character.slug)
label = f"Generator: {character.name}"
job = _enqueue_job(label, workflow, _finalize)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']} return {'status': 'queued', 'job_id': job['id']}
flash("Generation queued.") flash("Generation queued.")
return redirect(url_for('generator', preset=preset_slug))
except Exception as e: except Exception as e:
print(f"Generator error: {e}") logger.exception("Generator error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500 return {'error': str(e)}, 500
flash(f"Error: {str(e)}") flash(f"Error: {str(e)}")
return redirect(url_for('generator', preset=preset_slug))
return render_template('generator.html', characters=characters, checkpoints=checkpoints, @app.route('/generator/preset_info', methods=['GET'])
actions=actions, outfits=outfits, scenes=scenes, def generator_preset_info():
styles=styles, detailers=detailers, selected_ckpt=selected_ckpt) """Return resolved entity names for a preset (for the summary panel)."""
slug = request.args.get('slug', '')
if not slug:
return {'error': 'slug required'}, 400
@app.route('/generator/preview_prompt', methods=['POST']) preset = Preset.query.filter_by(slug=slug).first()
def generator_preview_prompt(): if not preset:
char_slug = request.form.get('character') return {'error': 'not found'}, 404
if not char_slug:
return {'error': 'No character selected'}, 400
character = Character.query.filter_by(slug=char_slug).first() data = preset.data
if not character: info = {}
return {'error': 'Character not found'}, 404
action_slugs = request.form.getlist('action_slugs') # Character
outfit_slugs = request.form.getlist('outfit_slugs') char_cfg = data.get('character', {})
scene_slugs = request.form.getlist('scene_slugs') char_id = char_cfg.get('character_id')
style_slugs = request.form.getlist('style_slugs') if char_id == 'random':
detailer_slugs = request.form.getlist('detailer_slugs') info['character'] = 'Random'
custom_positive = request.form.get('positive_prompt', '') elif char_id:
obj = _resolve_preset_entity('character', char_id)
info['character'] = obj.name if obj else char_id
else:
info['character'] = None
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else [] # Secondary entities
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else [] for key, label in [('outfit', 'outfit'), ('action', 'action'), ('style', 'style'),
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else [] ('scene', 'scene'), ('detailer', 'detailer'), ('look', 'look')]:
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else [] cfg = data.get(key, {})
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else [] eid = cfg.get(f'{key}_id')
if eid == 'random':
info[label] = 'Random'
elif eid:
obj = _resolve_preset_entity(key, eid)
info[label] = obj.name if obj else eid
else:
info[label] = None
prompts = build_prompt(character.data, default_fields=character.default_fields) # Checkpoint
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers) ckpt_cfg = data.get('checkpoint', {})
combined = prompts["main"] ckpt_path = ckpt_cfg.get('checkpoint_path')
if extras: if ckpt_path == 'random':
combined = f"{combined}, {extras}" info['checkpoint'] = 'Random'
if custom_positive: elif ckpt_path:
combined = f"{custom_positive}, {combined}" info['checkpoint'] = ckpt_path.split('/')[-1].replace('.safetensors', '')
else:
info['checkpoint'] = 'Default'
return {'prompt': combined, 'face': prompts['face'], 'hand': prompts['hand']} # Resolution
res_cfg = data.get('resolution', {})
if res_cfg.get('random'):
info['resolution'] = 'Random'
elif res_cfg.get('width') and res_cfg.get('height'):
info['resolution'] = f"{res_cfg['width']}x{res_cfg['height']}"
else:
info['resolution'] = 'Default'
return info

View File

@@ -1,7 +1,6 @@
import json import json
import os import os
import re import re
import time
import logging import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
from services.workflow import _prepare_workflow, _get_default_checkpoint from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags
from services.sync import sync_looks from services.sync import sync_looks
from services.file_io import get_available_loras, _count_look_assignments from services.file_io import get_available_loras, _count_look_assignments
@@ -58,9 +57,18 @@ def register_routes(app):
@app.route('/looks') @app.route('/looks')
def looks_index(): def looks_index():
looks = Look.query.order_by(Look.name).all() query = Look.query
fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
looks = query.order_by(Look.is_favourite.desc(), Look.name).all()
look_assignments = _count_look_assignments() look_assignments = _count_look_assignments()
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments) return render_template('looks/index.html', looks=looks, look_assignments=look_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/looks/rescan', methods=['POST']) @app.route('/looks/rescan', methods=['POST'])
def rescan_looks(): def rescan_looks():
@@ -144,8 +152,12 @@ def register_routes(app):
except ValueError: except ValueError:
pass pass
tags_raw = request.form.get('tags', '') new_data['tags'] = {
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()] 'origin_series': request.form.get('tag_origin_series', '').strip(),
'origin_type': request.form.get('tag_origin_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
look.is_nsfw = new_data['tags']['nsfw']
look.data = new_data look.data = new_data
flag_modified(look, 'data') flag_modified(look, 'data')
@@ -435,19 +447,32 @@ Character ID: {character_slug}"""
def create_look(): def create_look():
characters = Character.query.order_by(Character.name).all() characters = Character.query.order_by(Character.name).all()
loras = get_available_loras('characters') loras = get_available_loras('characters')
form_data = {}
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
filename = f'{look_id}.json'
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
character_id = request.form.get('character_id', '') or None character_id = request.form.get('character_id', '') or None
lora_name = request.form.get('lora_lora_name', '') lora_name = request.form.get('lora_lora_name', '')
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0) lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
lora_triggers = request.form.get('lora_lora_triggers', '') lora_triggers = request.form.get('lora_lora_triggers', '')
positive = request.form.get('positive', '') positive = request.form.get('positive', '')
negative = request.form.get('negative', '') negative = request.form.get('negative', '')
tags = [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()] tags = {
'origin_series': request.form.get('tag_origin_series', '').strip(),
'origin_type': request.form.get('tag_origin_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
form_data = {
'name': name, 'character_id': character_id,
'lora_lora_name': lora_name, 'lora_lora_weight': lora_weight,
'lora_lora_triggers': lora_triggers, 'positive': positive,
'negative': negative, 'tags': tags,
}
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
filename = f'{look_id}.json'
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
data = { data = {
'look_id': look_id, 'look_id': look_id,
@@ -459,20 +484,26 @@ Character ID: {character_slug}"""
'tags': tags 'tags': tags
} }
try:
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True) os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
with open(file_path, 'w') as f: with open(file_path, 'w') as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id) slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name, new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
character_id=character_id, data=data) character_id=character_id, data=data,
is_nsfw=tags.get('nsfw', False))
db.session.add(new_look) db.session.add(new_look)
db.session.commit() db.session.commit()
flash(f'Look "{name}" created!') flash(f'Look "{name}" created!')
return redirect(url_for('look_detail', slug=slug)) return redirect(url_for('look_detail', slug=slug))
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create look: {e}")
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
return render_template('looks/create.html', characters=characters, loras=loras) return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
@app.route('/get_missing_looks') @app.route('/get_missing_looks')
def get_missing_looks(): def get_missing_looks():
@@ -497,15 +528,16 @@ Character ID: {character_slug}"""
return redirect(url_for('looks_index')) return redirect(url_for('looks_index'))
overwrite = request.form.get('overwrite') == 'true' overwrite = request.form.get('overwrite') == 'true'
created_count = 0 skipped = 0
skipped_count = 0 job_ids = []
overwritten_count = 0
system_prompt = load_prompt('look_system.txt') system_prompt = load_prompt('look_system.txt')
if not system_prompt: if not system_prompt:
flash('Look system prompt file not found.', 'error') flash('Look system prompt file not found.', 'error')
return redirect(url_for('looks_index')) return redirect(url_for('looks_index'))
looks_dir = app.config['LOOKS_DIR']
for filename in os.listdir(lora_dir): for filename in os.listdir(lora_dir):
if not filename.endswith('.safetensors'): if not filename.endswith('.safetensors'):
continue continue
@@ -515,11 +547,11 @@ Character ID: {character_slug}"""
look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{look_id}.json" json_filename = f"{look_id}.json"
json_path = os.path.join(app.config['LOOKS_DIR'], json_filename) json_path = os.path.join(looks_dir, json_filename)
is_existing = os.path.exists(json_path) is_existing = os.path.exists(json_path)
if is_existing and not overwrite: if is_existing and not overwrite:
skipped_count += 1 skipped += 1
continue continue
html_filename = f"{name_base}.html" html_filename = f"{name_base}.html"
@@ -535,10 +567,10 @@ Character ID: {character_slug}"""
clean_html = re.sub(r'<[^>]+>', ' ', clean_html) clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split()) html_content = ' '.join(clean_html.split())
except Exception as e: except Exception as e:
print(f"Error reading HTML {html_filename}: {e}") logger.error("Error reading HTML %s: %s", html_filename, e)
try: def make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing):
print(f"Asking LLM to describe look: {look_name}") def task_fn(job):
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'" prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
if html_content: if html_content:
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###" prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
@@ -562,27 +594,32 @@ Character ID: {character_slug}"""
if look_data['lora'].get('lora_weight_max') is None: if look_data['lora'].get('lora_weight_max') is None:
look_data['lora']['lora_weight_max'] = 1.0 look_data['lora']['lora_weight_max'] = 1.0
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True) os.makedirs(looks_dir, exist_ok=True)
with open(json_path, 'w') as f: with open(json_path, 'w') as f:
json.dump(look_data, f, indent=2) json.dump(look_data, f, indent=2)
if is_existing: job['result'] = {'name': look_name, 'action': 'overwritten' if is_existing else 'created'}
overwritten_count += 1 return task_fn
else:
created_count += 1
time.sleep(0.5) job = _enqueue_task(f"Create look: {look_name}", make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing))
job_ids.append(job['id'])
except Exception as e: if job_ids:
print(f"Error creating look for {filename}: {e}") def sync_task(job):
if created_count > 0 or overwritten_count > 0:
sync_looks() sync_looks()
msg = f'Successfully processed looks: {created_count} created, {overwritten_count} overwritten.' job['result'] = {'synced': True}
if skipped_count > 0: _enqueue_task("Sync looks DB", sync_task)
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No looks created or overwritten. {skipped_count} existing entries found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
return redirect(url_for('looks_index')) return redirect(url_for('looks_index'))
@app.route('/look/<path:slug>/favourite', methods=['POST'])
def toggle_look_favourite(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
look.is_favourite = not look.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': look.is_favourite}
return redirect(url_for('look_detail', slug=slug))

View File

@@ -1,7 +1,6 @@
import json import json
import os import os
import re import re
import time
import logging import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint, Settings, Look from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint, Settings, Look
from services.workflow import _prepare_workflow, _get_default_checkpoint from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_outfits from services.sync import sync_outfits
from services.file_io import get_available_loras, _count_outfit_lora_assignments from services.file_io import get_available_loras, _count_outfit_lora_assignments
@@ -37,9 +36,18 @@ def register_routes(app):
@app.route('/outfits') @app.route('/outfits')
def outfits_index(): def outfits_index():
outfits = Outfit.query.order_by(Outfit.name).all() query = Outfit.query
fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
outfits = query.order_by(Outfit.is_favourite.desc(), Outfit.name).all()
lora_assignments = _count_outfit_lora_assignments() lora_assignments = _count_outfit_lora_assignments()
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments) return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/outfits/rescan', methods=['POST']) @app.route('/outfits/rescan', methods=['POST'])
def rescan_outfits(): def rescan_outfits():
@@ -53,20 +61,24 @@ def register_routes(app):
clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/') clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/')
_lora_subfolder = os.path.basename(clothing_lora_dir) _lora_subfolder = os.path.basename(clothing_lora_dir)
if not os.path.exists(clothing_lora_dir): if not os.path.exists(clothing_lora_dir):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Clothing LoRA directory not found.'}, 400
flash('Clothing LoRA directory not found.', 'error') flash('Clothing LoRA directory not found.', 'error')
return redirect(url_for('outfits_index')) return redirect(url_for('outfits_index'))
overwrite = request.form.get('overwrite') == 'true' overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('outfit_system.txt') system_prompt = load_prompt('outfit_system.txt')
if not system_prompt: if not system_prompt:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Outfit system prompt file not found.'}, 500
flash('Outfit system prompt file not found.', 'error') flash('Outfit system prompt file not found.', 'error')
return redirect(url_for('outfits_index')) return redirect(url_for('outfits_index'))
for filename in os.listdir(clothing_lora_dir): job_ids = []
skipped = 0
for filename in sorted(os.listdir(clothing_lora_dir)):
if not filename.endswith('.safetensors'): if not filename.endswith('.safetensors'):
continue continue
@@ -79,11 +91,11 @@ def register_routes(app):
is_existing = os.path.exists(json_path) is_existing = os.path.exists(json_path)
if is_existing and not overwrite: if is_existing and not overwrite:
skipped_count += 1 skipped += 1
continue continue
html_filename = f"{name_base}.html" # Read HTML companion file if it exists
html_path = os.path.join(clothing_lora_dir, html_filename) html_path = os.path.join(clothing_lora_dir, f"{name_base}.html")
html_content = "" html_content = ""
if os.path.exists(html_path): if os.path.exists(html_path):
try: try:
@@ -94,27 +106,27 @@ def register_routes(app):
clean_html = re.sub(r'<img[^>]*>', '', clean_html) clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html) clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split()) html_content = ' '.join(clean_html.split())
except Exception as e: except Exception:
print(f"Error reading HTML {html_filename}: {e}") pass
try: def make_task(fn, oid, oname, jp, lsf, html_ctx, sys_prompt, is_exist):
print(f"Asking LLM to describe outfit: {outfit_name}") def task_fn(job):
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{filename}'" prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{fn}'"
if html_content: if html_ctx:
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###" prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_ctx[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt) llm_response = call_llm(prompt, sys_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip() clean_json = llm_response.replace('```json', '').replace('```', '').strip()
outfit_data = json.loads(clean_json) outfit_data = json.loads(clean_json)
outfit_data['outfit_id'] = outfit_id outfit_data['outfit_id'] = oid
outfit_data['outfit_name'] = outfit_name outfit_data['outfit_name'] = oname
if 'lora' not in outfit_data: if 'lora' not in outfit_data:
outfit_data['lora'] = {} outfit_data['lora'] = {}
outfit_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" outfit_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
if not outfit_data['lora'].get('lora_triggers'): if not outfit_data['lora'].get('lora_triggers'):
outfit_data['lora']['lora_triggers'] = name_base outfit_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
if outfit_data['lora'].get('lora_weight') is None: if outfit_data['lora'].get('lora_weight') is None:
outfit_data['lora']['lora_weight'] = 0.8 outfit_data['lora']['lora_weight'] = 0.8
if outfit_data['lora'].get('lora_weight_min') is None: if outfit_data['lora'].get('lora_weight_min') is None:
@@ -122,29 +134,31 @@ def register_routes(app):
if outfit_data['lora'].get('lora_weight_max') is None: if outfit_data['lora'].get('lora_weight_max') is None:
outfit_data['lora']['lora_weight_max'] = 1.0 outfit_data['lora']['lora_weight_max'] = 1.0
os.makedirs(app.config['CLOTHING_DIR'], exist_ok=True) os.makedirs(os.path.dirname(jp), exist_ok=True)
with open(json_path, 'w') as f: with open(jp, 'w') as f:
json.dump(outfit_data, f, indent=2) json.dump(outfit_data, f, indent=2)
if is_existing: job['result'] = {'name': oname, 'action': 'overwritten' if is_exist else 'created'}
overwritten_count += 1 return task_fn
else:
created_count += 1
time.sleep(0.5) job = _enqueue_task(
f"Create outfit: {outfit_name}",
make_task(filename, outfit_id, outfit_name, json_path,
_lora_subfolder, html_content, system_prompt, is_existing)
)
job_ids.append(job['id'])
except Exception as e: # Enqueue a sync task to run after all creates
print(f"Error creating outfit for {filename}: {e}") if job_ids:
def sync_task(job):
if created_count > 0 or overwritten_count > 0:
sync_outfits() sync_outfits()
msg = f'Successfully processed outfits: {created_count} created, {overwritten_count} overwritten.' job['result'] = {'synced': True}
if skipped_count > 0: _enqueue_task("Sync outfits DB", sync_task)
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No outfits created or overwritten. {skipped_count} existing entries found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} outfit creation tasks ({skipped} skipped). Watch progress in the queue.')
return redirect(url_for('outfits_index')) return redirect(url_for('outfits_index'))
def _get_linked_characters_for_outfit(outfit): def _get_linked_characters_for_outfit(outfit):
@@ -232,9 +246,12 @@ def register_routes(app):
else: else:
new_data.setdefault('lora', {}).pop(bound, None) new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list) # Update Tags (structured dict)
tags_raw = request.form.get('tags', '') new_data['tags'] = {
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t] 'outfit_type': request.form.get('tag_outfit_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
outfit.is_nsfw = new_data['tags']['nsfw']
outfit.data = new_data outfit.data = new_data
flag_modified(outfit, "data") flag_modified(outfit, "data")
@@ -409,12 +426,16 @@ def register_routes(app):
@app.route('/outfit/create', methods=['GET', 'POST']) @app.route('/outfit/create', methods=['GET', 'POST'])
def create_outfit(): def create_outfit():
form_data = {}
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name') name = request.form.get('name')
slug = request.form.get('filename', '').strip() slug = request.form.get('filename', '').strip()
prompt = request.form.get('prompt', '') prompt = request.form.get('prompt', '')
use_llm = request.form.get('use_llm') == 'on' use_llm = request.form.get('use_llm') == 'on'
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
# Auto-generate slug from name if not provided # Auto-generate slug from name if not provided
if not slug: if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -435,13 +456,13 @@ def register_routes(app):
if use_llm: if use_llm:
if not prompt: if not prompt:
flash("Description is required when AI generation is enabled.") flash("Description is required when AI generation is enabled.")
return redirect(request.url) return render_template('outfits/create.html', form_data=form_data)
# Generate JSON with LLM # Generate JSON with LLM
system_prompt = load_prompt('outfit_system.txt') system_prompt = load_prompt('outfit_system.txt')
if not system_prompt: if not system_prompt:
flash("System prompt file not found.") flash("System prompt file not found.")
return redirect(request.url) return render_template('outfits/create.html', form_data=form_data)
try: try:
llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt) llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt)
@@ -477,7 +498,7 @@ def register_routes(app):
except Exception as e: except Exception as e:
print(f"LLM error: {e}") print(f"LLM error: {e}")
flash(f"Failed to generate outfit profile: {e}") flash(f"Failed to generate outfit profile: {e}")
return redirect(request.url) return render_template('outfits/create.html', form_data=form_data)
else: else:
# Create blank outfit template # Create blank outfit template
outfit_data = { outfit_data = {
@@ -523,9 +544,9 @@ def register_routes(app):
except Exception as e: except Exception as e:
print(f"Save error: {e}") print(f"Save error: {e}")
flash(f"Failed to create outfit: {e}") flash(f"Failed to create outfit: {e}")
return redirect(request.url) return render_template('outfits/create.html', form_data=form_data)
return render_template('outfits/create.html') return render_template('outfits/create.html', form_data=form_data)
@app.route('/outfit/<path:slug>/save_defaults', methods=['POST']) @app.route('/outfit/<path:slug>/save_defaults', methods=['POST'])
def save_outfit_defaults(slug): def save_outfit_defaults(slug):
@@ -601,3 +622,12 @@ def register_routes(app):
with open(file_path, 'w') as f: with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2) json.dump(new_data, f, indent=2)
return {'success': True} return {'success': True}
@app.route('/outfit/<path:slug>/favourite', methods=['POST'])
def toggle_outfit_favourite(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
outfit.is_favourite = not outfit.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': outfit.is_favourite}
return redirect(url_for('outfit_detail', slug=slug))

View File

@@ -149,6 +149,8 @@ def register_routes(app):
'use_lora': request.form.get('outfit_use_lora') == 'on'}, 'use_lora': request.form.get('outfit_use_lora') == 'on'},
'action': {'action_id': _entity_id(request.form.get('action_id')), 'action': {'action_id': _entity_id(request.form.get('action_id')),
'use_lora': request.form.get('action_use_lora') == 'on', 'use_lora': request.form.get('action_use_lora') == 'on',
'suppress_wardrobe': {'true': True, 'false': False, 'random': 'random'}.get(
request.form.get('act_suppress_wardrobe')),
'fields': {k: _tog(request.form.get(f'act_{k}', 'true')) 'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}}, for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
'style': {'style_id': _entity_id(request.form.get('style_id')), 'style': {'style_id': _entity_id(request.form.get('style_id')),
@@ -247,11 +249,15 @@ def register_routes(app):
@app.route('/preset/create', methods=['GET', 'POST']) @app.route('/preset/create', methods=['GET', 'POST'])
def create_preset(): def create_preset():
form_data = {}
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip() description = request.form.get('description', '').strip()
use_llm = request.form.get('use_llm') == 'on' use_llm = request.form.get('use_llm') == 'on'
form_data = {'name': name, 'description': description, 'use_llm': use_llm}
safe_id = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') or 'preset' safe_id = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') or 'preset'
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id) safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
base_id = safe_id base_id = safe_id
@@ -265,7 +271,7 @@ def register_routes(app):
system_prompt = load_prompt('preset_system.txt') system_prompt = load_prompt('preset_system.txt')
if not system_prompt: if not system_prompt:
flash('Preset system prompt file not found.', 'error') flash('Preset system prompt file not found.', 'error')
return redirect(request.url) return render_template('presets/create.html', form_data=form_data)
try: try:
llm_response = call_llm( llm_response = call_llm(
f"Create a preset profile named '{name}' based on this description: {description}", f"Create a preset profile named '{name}' based on this description: {description}",
@@ -276,7 +282,7 @@ def register_routes(app):
except Exception as e: except Exception as e:
logger.exception("LLM error creating preset: %s", e) logger.exception("LLM error creating preset: %s", e)
flash(f"AI generation failed: {e}", 'error') flash(f"AI generation failed: {e}", 'error')
return redirect(request.url) return render_template('presets/create.html', form_data=form_data)
else: else:
preset_data = { preset_data = {
'character': {'character_id': 'random', 'use_lora': True, 'character': {'character_id': 'random', 'use_lora': True,
@@ -314,7 +320,7 @@ def register_routes(app):
flash(f"Preset '{name}' created!") flash(f"Preset '{name}' created!")
return redirect(url_for('edit_preset', slug=safe_slug)) return redirect(url_for('edit_preset', slug=safe_slug))
return render_template('presets/create.html') return render_template('presets/create.html', form_data=form_data)
@app.route('/get_missing_presets') @app.route('/get_missing_presets')
def get_missing_presets(): def get_missing_presets():

View File

@@ -1,10 +1,14 @@
import logging import logging
from services.job_queue import ( from services.job_queue import (
_job_queue_lock, _job_queue, _job_history, _queue_worker_event, _job_queue_lock, _job_queue, _llm_queue, _job_history,
_queue_worker_event, _llm_worker_event,
) )
logger = logging.getLogger('gaze') logger = logging.getLogger('gaze')
# Both queues for iteration
_ALL_QUEUES = (_job_queue, _llm_queue)
def register_routes(app): def register_routes(app):
@@ -12,23 +16,27 @@ def register_routes(app):
def api_queue_list(): def api_queue_list():
"""Return the current queue as JSON.""" """Return the current queue as JSON."""
with _job_queue_lock: with _job_queue_lock:
jobs = [ jobs = []
{ for q in _ALL_QUEUES:
for j in q:
jobs.append({
'id': j['id'], 'id': j['id'],
'label': j['label'], 'label': j['label'],
'status': j['status'], 'status': j['status'],
'error': j['error'], 'error': j['error'],
'created_at': j['created_at'], 'created_at': j['created_at'],
} 'job_type': j.get('job_type', 'comfyui'),
for j in _job_queue })
]
return {'jobs': jobs, 'count': len(jobs)} return {'jobs': jobs, 'count': len(jobs)}
@app.route('/api/queue/count') @app.route('/api/queue/count')
def api_queue_count(): def api_queue_count():
"""Return just the count of active (non-done, non-failed) jobs.""" """Return just the count of active (non-done, non-failed) jobs."""
with _job_queue_lock: with _job_queue_lock:
count = sum(1 for j in _job_queue if j['status'] in ('pending', 'processing', 'paused')) count = sum(
1 for q in _ALL_QUEUES for j in q
if j['status'] in ('pending', 'processing', 'paused')
)
return {'count': count} return {'count': count}
@app.route('/api/queue/<job_id>/remove', methods=['POST']) @app.route('/api/queue/<job_id>/remove', methods=['POST'])
@@ -40,10 +48,12 @@ def register_routes(app):
return {'error': 'Job not found'}, 404 return {'error': 'Job not found'}, 404
if job['status'] == 'processing': if job['status'] == 'processing':
return {'error': 'Cannot remove a job that is currently processing'}, 400 return {'error': 'Cannot remove a job that is currently processing'}, 400
for q in _ALL_QUEUES:
try: try:
_job_queue.remove(job) q.remove(job)
break
except ValueError: except ValueError:
pass # Already not in queue continue
job['status'] = 'removed' job['status'] = 'removed'
return {'status': 'ok'} return {'status': 'ok'}
@@ -58,6 +68,10 @@ def register_routes(app):
job['status'] = 'paused' job['status'] = 'paused'
elif job['status'] == 'paused': elif job['status'] == 'paused':
job['status'] = 'pending' job['status'] = 'pending'
# Signal the appropriate worker
if job.get('job_type') == 'llm':
_llm_worker_event.set()
else:
_queue_worker_event.set() _queue_worker_event.set()
else: else:
return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400 return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400
@@ -65,13 +79,14 @@ def register_routes(app):
@app.route('/api/queue/clear', methods=['POST']) @app.route('/api/queue/clear', methods=['POST'])
def api_queue_clear(): def api_queue_clear():
"""Clear all pending jobs from the queue (allows current processing job to finish).""" """Clear all pending jobs from the queue (allows current processing jobs to finish)."""
removed_count = 0 removed_count = 0
with _job_queue_lock: with _job_queue_lock:
pending_jobs = [j for j in _job_queue if j['status'] == 'pending'] for q in _ALL_QUEUES:
pending_jobs = [j for j in q if j['status'] == 'pending']
for job in pending_jobs: for job in pending_jobs:
try: try:
_job_queue.remove(job) q.remove(job)
job['status'] = 'removed' job['status'] = 'removed'
removed_count += 1 removed_count += 1
except ValueError: except ValueError:
@@ -91,7 +106,8 @@ def register_routes(app):
'label': job['label'], 'label': job['label'],
'status': job['status'], 'status': job['status'],
'error': job['error'], 'error': job['error'],
'comfy_prompt_id': job['comfy_prompt_id'], 'job_type': job.get('job_type', 'comfyui'),
'comfy_prompt_id': job.get('comfy_prompt_id'),
} }
if job.get('result'): if job.get('result'):
resp['result'] = job['result'] resp['result'] = job['result']

202
routes/regenerate.py Normal file
View File

@@ -0,0 +1,202 @@
import json
import os
import logging
from flask import current_app
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint
from services.llm import load_prompt, call_llm
from services.sync import _sync_nsfw_from_tags
from services.job_queue import _enqueue_task
logger = logging.getLogger('gaze')
# Map category string to (model class, id_field, config_dir_key)
_CATEGORY_MAP = {
'characters': (Character, 'character_id', 'CHARACTERS_DIR'),
'outfits': (Outfit, 'outfit_id', 'CLOTHING_DIR'),
'actions': (Action, 'action_id', 'ACTIONS_DIR'),
'styles': (Style, 'style_id', 'STYLES_DIR'),
'scenes': (Scene, 'scene_id', 'SCENES_DIR'),
'detailers': (Detailer, 'detailer_id', 'DETAILERS_DIR'),
'looks': (Look, 'look_id', 'LOOKS_DIR'),
}
# Fields to preserve from the original data (never overwritten by LLM output)
_PRESERVE_KEYS = {
'lora', 'participants', 'suppress_wardrobe',
'character_id', 'character_name',
'outfit_id', 'outfit_name',
'action_id', 'action_name',
'style_id', 'style_name',
'scene_id', 'scene_name',
'detailer_id', 'detailer_name',
'look_id', 'look_name',
}
def register_routes(app):
@app.route('/api/<category>/<path:slug>/regenerate_tags', methods=['POST'])
def regenerate_tags(category, slug):
if category not in _CATEGORY_MAP:
return {'error': f'Unknown category: {category}'}, 400
model_class, id_field, dir_key = _CATEGORY_MAP[category]
entity = model_class.query.filter_by(slug=slug).first()
if not entity:
return {'error': 'Not found'}, 404
system_prompt = load_prompt('regenerate_tags_system.txt')
if not system_prompt:
return {'error': 'Regenerate tags system prompt not found'}, 500
original_data = entity.data.copy()
try:
prompt = (
f"Regenerate the prompt tags for this {category.rstrip('s')} resource.\n"
f"Current JSON:\n{json.dumps(original_data, indent=2)}"
)
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
new_data = json.loads(clean_json)
except Exception as e:
logger.exception(f"Regenerate tags LLM error for {category}/{slug}")
return {'error': f'LLM error: {str(e)}'}, 500
# Preserve protected fields from original
for key in _PRESERVE_KEYS:
if key in original_data:
new_data[key] = original_data[key]
# Update DB
entity.data = new_data
flag_modified(entity, 'data')
_sync_nsfw_from_tags(entity, new_data)
db.session.commit()
# Write back to JSON file
if entity.filename:
file_path = os.path.join(current_app.config[dir_key], entity.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True, 'data': new_data}
@app.route('/admin/migrate_tags', methods=['POST'])
def migrate_tags():
"""One-time migration: convert old list-format tags to new dict format."""
migrated = 0
for category, (model_class, id_field, dir_key) in _CATEGORY_MAP.items():
entities = model_class.query.all()
for entity in entities:
tags = entity.data.get('tags')
if isinstance(tags, list) or tags is None:
new_data = entity.data.copy()
new_data['tags'] = {'nsfw': False}
entity.data = new_data
flag_modified(entity, 'data')
# Write back to JSON file
if entity.filename:
file_path = os.path.join(current_app.config[dir_key], entity.filename)
try:
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
except Exception as e:
logger.warning(f"Could not write {file_path}: {e}")
migrated += 1
# Also handle checkpoints
for ckpt in Checkpoint.query.all():
data = ckpt.data or {}
tags = data.get('tags')
if isinstance(tags, list) or tags is None:
new_data = data.copy()
new_data['tags'] = {'nsfw': False}
ckpt.data = new_data
flag_modified(ckpt, 'data')
migrated += 1
db.session.commit()
logger.info(f"Migrated {migrated} resources from list tags to dict tags")
return {'success': True, 'migrated': migrated}
def _make_regen_task(category, slug, name, system_prompt):
"""Factory: create a tag regeneration task function for one entity."""
def task_fn(job):
model_class, id_field, dir_key = _CATEGORY_MAP[category]
entity = model_class.query.filter_by(slug=slug).first()
if not entity:
raise Exception(f'{category}/{slug} not found')
original_data = entity.data.copy()
prompt = (
f"Regenerate the prompt tags for this {category.rstrip('s')} resource.\n"
f"Current JSON:\n{json.dumps(original_data, indent=2)}"
)
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
new_data = json.loads(clean_json)
for key in _PRESERVE_KEYS:
if key in original_data:
new_data[key] = original_data[key]
entity.data = new_data
flag_modified(entity, 'data')
_sync_nsfw_from_tags(entity, new_data)
db.session.commit()
if entity.filename:
file_path = os.path.join(current_app.config[dir_key], entity.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
job['result'] = {'entity': name, 'status': 'updated'}
return task_fn
@app.route('/admin/bulk_regenerate_tags/<category>', methods=['POST'])
def bulk_regenerate_tags_category(category):
"""Queue LLM tag regeneration for all resources in a single category."""
if category not in _CATEGORY_MAP:
return {'error': f'Unknown category: {category}'}, 400
system_prompt = load_prompt('regenerate_tags_system.txt')
if not system_prompt:
return {'error': 'Regenerate tags system prompt not found'}, 500
model_class, id_field, dir_key = _CATEGORY_MAP[category]
entities = model_class.query.all()
job_ids = []
for entity in entities:
job = _enqueue_task(
f"Regen tags: {entity.name} ({category})",
_make_regen_task(category, entity.slug, entity.name, system_prompt)
)
job_ids.append(job['id'])
return {'success': True, 'queued': len(job_ids), 'job_ids': job_ids}
@app.route('/admin/bulk_regenerate_tags', methods=['POST'])
def bulk_regenerate_tags():
"""Queue LLM tag regeneration for all resources across all categories."""
system_prompt = load_prompt('regenerate_tags_system.txt')
if not system_prompt:
return {'error': 'Regenerate tags system prompt not found'}, 500
job_ids = []
for category, (model_class, id_field, dir_key) in _CATEGORY_MAP.items():
entities = model_class.query.all()
for entity in entities:
job = _enqueue_task(
f"Regen tags: {entity.name} ({category})",
_make_regen_task(category, entity.slug, entity.name, system_prompt)
)
job_ids.append(job['id'])
return {'success': True, 'queued': len(job_ids), 'job_ids': job_ids}

View File

@@ -1,7 +1,6 @@
import json import json
import os import os
import re import re
import time
import logging import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look
from services.workflow import _prepare_workflow, _get_default_checkpoint from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_scenes from services.sync import sync_scenes
from services.file_io import get_available_loras from services.file_io import get_available_loras
@@ -37,8 +36,17 @@ def register_routes(app):
@app.route('/scenes') @app.route('/scenes')
def scenes_index(): def scenes_index():
scenes = Scene.query.order_by(Scene.name).all() query = Scene.query
return render_template('scenes/index.html', scenes=scenes) fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
scenes = query.order_by(Scene.is_favourite.desc(), Scene.name).all()
return render_template('scenes/index.html', scenes=scenes, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/scenes/rescan', methods=['POST']) @app.route('/scenes/rescan', methods=['POST'])
def rescan_scenes(): def rescan_scenes():
@@ -117,9 +125,12 @@ def register_routes(app):
else: else:
new_data.setdefault('lora', {}).pop(bound, None) new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list) # Update Tags (structured dict)
tags_raw = request.form.get('tags', '') new_data['tags'] = {
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()] 'scene_type': request.form.get('tag_scene_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
scene.is_nsfw = new_data['tags']['nsfw']
scene.data = new_data scene.data = new_data
flag_modified(scene, "data") flag_modified(scene, "data")
@@ -332,15 +343,16 @@ def register_routes(app):
return redirect(url_for('scenes_index')) return redirect(url_for('scenes_index'))
overwrite = request.form.get('overwrite') == 'true' overwrite = request.form.get('overwrite') == 'true'
created_count = 0 skipped = 0
skipped_count = 0 job_ids = []
overwritten_count = 0
system_prompt = load_prompt('scene_system.txt') system_prompt = load_prompt('scene_system.txt')
if not system_prompt: if not system_prompt:
flash('Scene system prompt file not found.', 'error') flash('Scene system prompt file not found.', 'error')
return redirect(url_for('scenes_index')) return redirect(url_for('scenes_index'))
scenes_dir = app.config['SCENES_DIR']
for filename in os.listdir(backgrounds_lora_dir): for filename in os.listdir(backgrounds_lora_dir):
if filename.endswith('.safetensors'): if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0] name_base = filename.rsplit('.', 1)[0]
@@ -348,11 +360,11 @@ def register_routes(app):
scene_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() scene_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{scene_id}.json" json_filename = f"{scene_id}.json"
json_path = os.path.join(app.config['SCENES_DIR'], json_filename) json_path = os.path.join(scenes_dir, json_filename)
is_existing = os.path.exists(json_path) is_existing = os.path.exists(json_path)
if is_existing and not overwrite: if is_existing and not overwrite:
skipped_count += 1 skipped += 1
continue continue
html_filename = f"{name_base}.html" html_filename = f"{name_base}.html"
@@ -362,28 +374,24 @@ def register_routes(app):
try: try:
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf: with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
html_raw = hf.read() html_raw = hf.read()
# Strip HTML tags but keep text content for LLM context
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL) clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL) clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', clean_html) clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html) clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split()) html_content = ' '.join(clean_html.split())
except Exception as e: except Exception as e:
print(f"Error reading HTML {html_filename}: {e}") logger.error("Error reading HTML %s: %s", html_filename, e)
try: def make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
print(f"Asking LLM to describe scene: {scene_name}") def task_fn(job):
prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'" prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'"
if html_content: if html_content:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###" prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt) llm_response = call_llm(prompt, system_prompt)
# Clean response
clean_json = llm_response.replace('```json', '').replace('```', '').strip() clean_json = llm_response.replace('```json', '').replace('```', '').strip()
scene_data = json.loads(clean_json) scene_data = json.loads(clean_json)
# Enforce system values while preserving LLM-extracted metadata
scene_data['scene_id'] = scene_id scene_data['scene_id'] = scene_id
scene_data['scene_name'] = scene_name scene_data['scene_name'] = scene_name
@@ -402,34 +410,33 @@ def register_routes(app):
with open(json_path, 'w') as f: with open(json_path, 'w') as f:
json.dump(scene_data, f, indent=2) json.dump(scene_data, f, indent=2)
if is_existing: job['result'] = {'name': scene_name, 'action': 'overwritten' if is_existing else 'created'}
overwritten_count += 1 return task_fn
else:
created_count += 1
# Small delay to avoid API rate limits if many files job = _enqueue_task(f"Create scene: {scene_name}", make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
time.sleep(0.5) job_ids.append(job['id'])
except Exception as e: if job_ids:
print(f"Error creating scene for {filename}: {e}") def sync_task(job):
if created_count > 0 or overwritten_count > 0:
sync_scenes() sync_scenes()
msg = f'Successfully processed scenes: {created_count} created, {overwritten_count} overwritten.' job['result'] = {'synced': True}
if skipped_count > 0: _enqueue_task("Sync scenes DB", sync_task)
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No scenes created or overwritten. {skipped_count} existing scenes found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} scene tasks ({skipped} skipped).')
return redirect(url_for('scenes_index')) return redirect(url_for('scenes_index'))
@app.route('/scene/create', methods=['GET', 'POST']) @app.route('/scene/create', methods=['GET', 'POST'])
def create_scene(): def create_scene():
form_data = {}
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name') name = request.form.get('name')
slug = request.form.get('filename', '').strip() slug = request.form.get('filename', '').strip()
form_data = {'name': name, 'filename': slug}
if not slug: if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -478,9 +485,9 @@ def register_routes(app):
except Exception as e: except Exception as e:
print(f"Save error: {e}") print(f"Save error: {e}")
flash(f"Failed to create scene: {e}") flash(f"Failed to create scene: {e}")
return redirect(request.url) return render_template('scenes/create.html', form_data=form_data)
return render_template('scenes/create.html') return render_template('scenes/create.html', form_data=form_data)
@app.route('/scene/<path:slug>/clone', methods=['POST']) @app.route('/scene/<path:slug>/clone', methods=['POST'])
def clone_scene(slug): def clone_scene(slug):
@@ -538,3 +545,12 @@ def register_routes(app):
with open(file_path, 'w') as f: with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2) json.dump(new_data, f, indent=2)
return {'success': True} return {'success': True}
@app.route('/scene/<path:slug>/favourite', methods=['POST'])
def toggle_scene_favourite(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
scene.is_favourite = not scene.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': scene.is_favourite}
return redirect(url_for('scene_detail', slug=slug))

209
routes/search.py Normal file
View File

@@ -0,0 +1,209 @@
import json
import os
import logging
from flask import render_template, request, current_app
from models import (
db, Character, Look, Outfit, Action, Style, Scene, Detailer, Checkpoint,
)
logger = logging.getLogger('gaze')
# Category config: (model_class, id_field, name_field, url_prefix, detail_route)
_SEARCH_CATEGORIES = {
'characters': (Character, 'character_id', 'character_name', '/character', 'detail'),
'looks': (Look, 'look_id', 'look_name', '/look', 'look_detail'),
'outfits': (Outfit, 'outfit_id', 'outfit_name', '/outfit', 'outfit_detail'),
'actions': (Action, 'action_id', 'action_name', '/action', 'action_detail'),
'styles': (Style, 'style_id', 'style_name', '/style', 'style_detail'),
'scenes': (Scene, 'scene_id', 'scene_name', '/scene', 'scene_detail'),
'detailers': (Detailer, 'detailer_id', 'detailer_name', '/detailer', 'detailer_detail'),
'checkpoints': (Checkpoint, 'checkpoint_id', 'checkpoint_name', '/checkpoint', 'checkpoint_detail'),
}
def register_routes(app):
def _search_resources(query_str, category_filter='all', nsfw_filter='all'):
"""Search resources by name, tag values, and prompt field content."""
results = []
q = query_str.lower()
categories = _SEARCH_CATEGORIES if category_filter == 'all' else {category_filter: _SEARCH_CATEGORIES.get(category_filter)}
categories = {k: v for k, v in categories.items() if v}
for cat_name, (model_class, id_field, name_field, url_prefix, detail_route) in categories.items():
entities = model_class.query.all()
for entity in entities:
# Apply NSFW filter
if nsfw_filter == 'sfw' and getattr(entity, 'is_nsfw', False):
continue
if nsfw_filter == 'nsfw' and not getattr(entity, 'is_nsfw', False):
continue
match_context = None
score = 0
# 1. Name match (highest priority)
if q in entity.name.lower():
score = 100 if entity.name.lower() == q else 90
match_context = f"Name: {entity.name}"
# 2. Tag match
if not match_context:
data = entity.data or {}
tags = data.get('tags', {})
if isinstance(tags, dict):
for key, val in tags.items():
if key == 'nsfw':
continue
if isinstance(val, str) and q in val.lower():
score = 70
match_context = f"Tag {key}: {val}"
break
elif isinstance(val, list):
for item in val:
if isinstance(item, str) and q in item.lower():
score = 70
match_context = f"Tag {key}: {item}"
break
# 3. Prompt field match (search JSON data values)
if not match_context:
data_str = json.dumps(entity.data or {}).lower()
if q in data_str:
score = 30
# Find which field matched for context
data = entity.data or {}
for section_key, section_val in data.items():
if section_key in ('lora', 'tags'):
continue
if isinstance(section_val, dict):
for field_key, field_val in section_val.items():
if isinstance(field_val, str) and q in field_val.lower():
match_context = f"{section_key}.{field_key}: ...{field_val[:80]}..."
break
elif isinstance(section_val, str) and q in section_val.lower():
match_context = f"{section_key}: ...{section_val[:80]}..."
if match_context:
break
if not match_context:
match_context = "Matched in data"
if match_context:
results.append({
'type': cat_name,
'slug': entity.slug,
'name': entity.name,
'match_context': match_context,
'score': score,
'is_favourite': getattr(entity, 'is_favourite', False),
'is_nsfw': getattr(entity, 'is_nsfw', False),
'image_path': entity.image_path,
'detail_route': detail_route,
})
results.sort(key=lambda x: (-x['score'], x['name'].lower()))
return results
def _search_images(query_str, nsfw_filter='all'):
"""Search gallery images by prompt metadata in sidecar JSON files."""
results = []
q = query_str.lower()
upload_folder = app.config['UPLOAD_FOLDER']
gallery_cats = ['characters', 'actions', 'outfits', 'scenes', 'styles',
'detailers', 'checkpoints', 'looks', 'presets', 'generator']
for cat in gallery_cats:
cat_folder = os.path.join(upload_folder, cat)
if not os.path.isdir(cat_folder):
continue
try:
slugs = os.listdir(cat_folder)
except OSError:
continue
for item_slug in slugs:
item_folder = os.path.join(cat_folder, item_slug)
if not os.path.isdir(item_folder):
continue
try:
files = os.listdir(item_folder)
except OSError:
continue
for filename in files:
if not filename.lower().endswith('.json'):
continue
# This is a sidecar JSON
sidecar_path = os.path.join(item_folder, filename)
try:
with open(sidecar_path) as f:
sidecar = json.load(f)
except (OSError, json.JSONDecodeError):
continue
# NSFW filter
if nsfw_filter == 'sfw' and sidecar.get('is_nsfw'):
continue
if nsfw_filter == 'nsfw' and not sidecar.get('is_nsfw'):
continue
# Search positive/negative prompts stored in sidecar
sidecar_str = json.dumps(sidecar).lower()
if q in sidecar_str:
img_filename = filename.rsplit('.', 1)[0]
# Find the actual image file
img_path = None
for ext in ('.png', '.jpg', '.jpeg', '.webp'):
candidate = f"{cat}/{item_slug}/{img_filename}{ext}"
if os.path.isfile(os.path.join(upload_folder, candidate)):
img_path = candidate
break
if img_path:
results.append({
'path': img_path,
'category': cat,
'slug': item_slug,
'is_favourite': sidecar.get('is_favourite', False),
'is_nsfw': sidecar.get('is_nsfw', False),
'match_context': f"Found in sidecar metadata",
})
if len(results) >= 50:
break
return results[:50]
@app.route('/search')
def search():
q = request.args.get('q', '').strip()
category = request.args.get('category', 'all')
nsfw = request.args.get('nsfw', 'all')
search_type = request.args.get('type', 'all')
resources = []
images = []
if q:
if search_type in ('all', 'resources'):
resources = _search_resources(q, category, nsfw)
if search_type in ('all', 'images'):
images = _search_images(q, nsfw)
# Group resources by type
grouped = {}
for r in resources:
grouped.setdefault(r['type'], []).append(r)
return render_template(
'search.html',
query=q,
category=category,
nsfw_filter=nsfw,
search_type=search_type,
grouped_resources=grouped,
images=images,
total_resources=len(resources),
total_images=len(images),
)

View File

@@ -70,7 +70,6 @@ def register_routes(app):
if category == 'outfits': if category == 'outfits':
wardrobe = entity.data.get('wardrobe', {}) wardrobe = entity.data.get('wardrobe', {})
outfit_triggers = entity.data.get('lora', {}).get('lora_triggers', '') outfit_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
wardrobe_parts = [v for v in wardrobe.values() if isinstance(v, str) and v] wardrobe_parts = [v for v in wardrobe.values() if isinstance(v, str) and v]
char_parts = [] char_parts = []
face_parts = [] face_parts = []
@@ -83,7 +82,7 @@ def register_routes(app):
face_parts = [v for v in [identity.get('head'), face_parts = [v for v in [identity.get('head'),
defaults.get('expression')] if v] defaults.get('expression')] if v]
hand_parts = [v for v in [wardrobe.get('hands')] if v] hand_parts = [v for v in [wardrobe.get('hands')] if v]
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts
return { return {
'main': _dedup_tags(', '.join(p for p in main_parts if p)), 'main': _dedup_tags(', '.join(p for p in main_parts if p)),
'face': _dedup_tags(', '.join(face_parts)), 'face': _dedup_tags(', '.join(face_parts)),
@@ -93,7 +92,6 @@ def register_routes(app):
if category == 'actions': if category == 'actions':
action_data = entity.data.get('action', {}) action_data = entity.data.get('action', {})
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '') action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
from utils import _BODY_GROUP_KEYS from utils import _BODY_GROUP_KEYS
pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)] pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
expr_parts = [action_data.get('head', '')] if action_data.get('head') else [] expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
@@ -104,7 +102,7 @@ def register_routes(app):
identity = character.data.get('identity', {}) identity = character.data.get('identity', {})
char_parts = [v for v in [identity.get('base'), identity.get('head')] if v] char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
face_parts = [v for v in [identity.get('head')] + expr_parts if v] face_parts = [v for v in [identity.get('head')] + expr_parts if v]
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts
return { return {
'main': _dedup_tags(', '.join(p for p in main_parts if p)), 'main': _dedup_tags(', '.join(p for p in main_parts if p)),
'face': _dedup_tags(', '.join(face_parts)), 'face': _dedup_tags(', '.join(face_parts)),
@@ -113,20 +111,19 @@ def register_routes(app):
# styles / scenes / detailers # styles / scenes / detailers
entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '') entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
if category == 'styles': if category == 'styles':
sdata = entity.data.get('style', {}) sdata = entity.data.get('style', {})
artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else '' artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else ''
style_tags = sdata.get('artistic_style', '') style_tags = sdata.get('artistic_style', '')
entity_parts = [p for p in [entity_triggers, artist, style_tags] + tags if p] entity_parts = [p for p in [entity_triggers, artist, style_tags] if p]
elif category == 'scenes': elif category == 'scenes':
sdata = entity.data.get('scene', {}) sdata = entity.data.get('scene', {})
scene_parts = [v for v in sdata.values() if isinstance(v, str) and v] scene_parts = [v for v in sdata.values() if isinstance(v, str) and v]
entity_parts = [p for p in [entity_triggers] + scene_parts + tags if p] entity_parts = [p for p in [entity_triggers] + scene_parts if p]
else: # detailers else: # detailers
det_prompt = entity.data.get('prompt', '') det_prompt = entity.data.get('prompt', '')
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p] entity_parts = [p for p in [entity_triggers, det_prompt] if p]
char_data_no_lora = _get_character_data_without_lora(character) char_data_no_lora = _get_character_data_without_lora(character)
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''} base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''}

View File

@@ -2,7 +2,6 @@ import json
import os import os
import re import re
import random import random
import time
import logging import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Style, Detailer, Settings from models import db, Character, Style, Detailer, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_styles from services.sync import sync_styles
from services.file_io import get_available_loras from services.file_io import get_available_loras
@@ -82,8 +81,17 @@ def register_routes(app):
@app.route('/styles') @app.route('/styles')
def styles_index(): def styles_index():
styles = Style.query.order_by(Style.name).all() query = Style.query
return render_template('styles/index.html', styles=styles) fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
styles = query.order_by(Style.is_favourite.desc(), Style.name).all()
return render_template('styles/index.html', styles=styles, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/styles/rescan', methods=['POST']) @app.route('/styles/rescan', methods=['POST'])
def rescan_styles(): def rescan_styles():
@@ -158,6 +166,13 @@ def register_routes(app):
else: else:
new_data.setdefault('lora', {}).pop(bound, None) new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (structured dict)
new_data['tags'] = {
'style_type': request.form.get('tag_style_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
style.is_nsfw = new_data['tags']['nsfw']
style.data = new_data style.data = new_data
flag_modified(style, "data") flag_modified(style, "data")
@@ -343,21 +358,27 @@ def register_routes(app):
styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/') styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/')
_lora_subfolder = os.path.basename(styles_lora_dir) _lora_subfolder = os.path.basename(styles_lora_dir)
if not os.path.exists(styles_lora_dir): if not os.path.exists(styles_lora_dir):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Styles LoRA directory not found.'}, 400
flash('Styles LoRA directory not found.', 'error') flash('Styles LoRA directory not found.', 'error')
return redirect(url_for('styles_index')) return redirect(url_for('styles_index'))
overwrite = request.form.get('overwrite') == 'true' overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('style_system.txt') system_prompt = load_prompt('style_system.txt')
if not system_prompt: if not system_prompt:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Style system prompt file not found.'}, 500
flash('Style system prompt file not found.', 'error') flash('Style system prompt file not found.', 'error')
return redirect(url_for('styles_index')) return redirect(url_for('styles_index'))
for filename in os.listdir(styles_lora_dir): job_ids = []
if filename.endswith('.safetensors'): skipped = 0
for filename in sorted(os.listdir(styles_lora_dir)):
if not filename.endswith('.safetensors'):
continue
name_base = filename.rsplit('.', 1)[0] name_base = filename.rsplit('.', 1)[0]
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
@@ -367,11 +388,11 @@ def register_routes(app):
is_existing = os.path.exists(json_path) is_existing = os.path.exists(json_path)
if is_existing and not overwrite: if is_existing and not overwrite:
skipped_count += 1 skipped += 1
continue continue
html_filename = f"{name_base}.html" # Read HTML companion file if it exists
html_path = os.path.join(styles_lora_dir, html_filename) html_path = os.path.join(styles_lora_dir, f"{name_base}.html")
html_content = "" html_content = ""
if os.path.exists(html_path): if os.path.exists(html_path):
try: try:
@@ -382,27 +403,28 @@ def register_routes(app):
clean_html = re.sub(r'<img[^>]*>', '', clean_html) clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html) clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split()) html_content = ' '.join(clean_html.split())
except Exception as e: except Exception:
print(f"Error reading HTML {html_filename}: {e}") pass
try: def make_task(fn, sid, sname, jp, lsf, html_ctx, sys_prompt, is_exist):
print(f"Asking LLM to describe style: {style_name}") def task_fn(job):
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{filename}'" prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{fn}'"
if html_content: if html_ctx:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###" prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt) llm_response = call_llm(prompt, sys_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip() clean_json = llm_response.replace('```json', '').replace('```', '').strip()
style_data = json.loads(clean_json) style_data = json.loads(clean_json)
style_data['style_id'] = style_id style_data['style_id'] = sid
style_data['style_name'] = style_name style_data['style_name'] = sname
if 'lora' not in style_data: style_data['lora'] = {} if 'lora' not in style_data:
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" style_data['lora'] = {}
style_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
if not style_data['lora'].get('lora_triggers'): if not style_data['lora'].get('lora_triggers'):
style_data['lora']['lora_triggers'] = name_base style_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
if style_data['lora'].get('lora_weight') is None: if style_data['lora'].get('lora_weight') is None:
style_data['lora']['lora_weight'] = 1.0 style_data['lora']['lora_weight'] = 1.0
if style_data['lora'].get('lora_weight_min') is None: if style_data['lora'].get('lora_weight_min') is None:
@@ -410,35 +432,43 @@ def register_routes(app):
if style_data['lora'].get('lora_weight_max') is None: if style_data['lora'].get('lora_weight_max') is None:
style_data['lora']['lora_weight_max'] = 1.0 style_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f: os.makedirs(os.path.dirname(jp), exist_ok=True)
with open(jp, 'w') as f:
json.dump(style_data, f, indent=2) json.dump(style_data, f, indent=2)
if is_existing: job['result'] = {'name': sname, 'action': 'overwritten' if is_exist else 'created'}
overwritten_count += 1 return task_fn
else:
created_count += 1
time.sleep(0.5) job = _enqueue_task(
except Exception as e: f"Create style: {style_name}",
print(f"Error creating style for {filename}: {e}") make_task(filename, style_id, style_name, json_path,
_lora_subfolder, html_content, system_prompt, is_existing)
)
job_ids.append(job['id'])
if created_count > 0 or overwritten_count > 0: # Enqueue a sync task to run after all creates
if job_ids:
def sync_task(job):
sync_styles() sync_styles()
msg = f'Successfully processed styles: {created_count} created, {overwritten_count} overwritten.' job['result'] = {'synced': True}
if skipped_count > 0: _enqueue_task("Sync styles DB", sync_task)
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No styles created or overwritten. {skipped_count} existing styles found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} style creation tasks ({skipped} skipped). Watch progress in the queue.')
return redirect(url_for('styles_index')) return redirect(url_for('styles_index'))
@app.route('/style/create', methods=['GET', 'POST']) @app.route('/style/create', methods=['GET', 'POST'])
def create_style(): def create_style():
form_data = {}
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name') name = request.form.get('name')
slug = request.form.get('filename', '').strip() slug = request.form.get('filename', '').strip()
form_data = {'name': name, 'filename': slug}
if not slug: if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -483,9 +513,9 @@ def register_routes(app):
except Exception as e: except Exception as e:
print(f"Save error: {e}") print(f"Save error: {e}")
flash(f"Failed to create style: {e}") flash(f"Failed to create style: {e}")
return redirect(request.url) return render_template('styles/create.html', form_data=form_data)
return render_template('styles/create.html') return render_template('styles/create.html', form_data=form_data)
@app.route('/style/<path:slug>/clone', methods=['POST']) @app.route('/style/<path:slug>/clone', methods=['POST'])
def clone_style(slug): def clone_style(slug):
@@ -542,3 +572,12 @@ def register_routes(app):
with open(file_path, 'w') as f: with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2) json.dump(new_data, f, indent=2)
return {'success': True} return {'success': True}
@app.route('/style/<path:slug>/favourite', methods=['POST'])
def toggle_style_favourite(slug):
style_obj = Style.query.filter_by(slug=slug).first_or_404()
style_obj.is_favourite = not style_obj.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': style_obj.is_favourite}
return redirect(url_for('style_detail', slug=slug))

View File

@@ -11,13 +11,14 @@ from services.sync import _resolve_preset_entity, _resolve_preset_fields
logger = logging.getLogger('gaze') logger = logging.getLogger('gaze')
def generate_from_preset(preset, overrides=None): def generate_from_preset(preset, overrides=None, save_category='presets'):
"""Execute preset-based generation. """Execute preset-based generation.
Args: Args:
preset: Preset ORM object preset: Preset ORM object
overrides: optional dict with keys: overrides: optional dict with keys:
checkpoint, extra_positive, extra_negative, seed, width, height, action checkpoint, extra_positive, extra_negative, seed, width, height, action
save_category: upload sub-directory ('presets' or 'generator')
Returns: Returns:
job dict from _enqueue_job() job dict from _enqueue_job()
@@ -52,6 +53,19 @@ def generate_from_preset(preset, overrides=None):
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id')) detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id')) look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
# Build sidecar metadata with resolved entity slugs
resolved_meta = {
'preset_slug': preset.slug,
'preset_name': preset.name,
'character_slug': character.slug if character else None,
'outfit_slug': outfit.slug if outfit else None,
'action_slug': action_obj.slug if action_obj else None,
'style_slug': style_obj.slug if style_obj else None,
'scene_slug': scene_obj.slug if scene_obj else None,
'detailer_slug': detailer_obj.slug if detailer_obj else None,
'look_slug': look_obj.slug if look_obj else None,
}
# Checkpoint: override > preset config > default # Checkpoint: override > preset config > default
checkpoint_override = overrides.get('checkpoint', '').strip() if overrides.get('checkpoint') else '' checkpoint_override = overrides.get('checkpoint', '').strip() if overrides.get('checkpoint') else ''
if checkpoint_override: if checkpoint_override:
@@ -71,14 +85,31 @@ def generate_from_preset(preset, overrides=None):
else: else:
ckpt_path, ckpt_data = _get_default_checkpoint() ckpt_path, ckpt_data = _get_default_checkpoint()
resolved_meta['checkpoint_path'] = ckpt_path
# Resolve selected fields from preset toggles # Resolve selected fields from preset toggles
selected_fields = _resolve_preset_fields(data) selected_fields = _resolve_preset_fields(data)
# Check suppress_wardrobe: preset override > action default
suppress_wardrobe = False
preset_suppress = action_cfg.get('suppress_wardrobe')
if preset_suppress == 'random':
suppress_wardrobe = random.choice([True, False])
elif preset_suppress is not None:
suppress_wardrobe = bool(preset_suppress)
elif action_obj:
suppress_wardrobe = action_obj.data.get('suppress_wardrobe', False)
if suppress_wardrobe:
selected_fields = [f for f in selected_fields if not f.startswith('wardrobe::')]
# Build combined data for prompt building # Build combined data for prompt building
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default') active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
if wardrobe_source is None: if wardrobe_source is None:
wardrobe_source = character.get_active_wardrobe() if character else {} wardrobe_source = character.get_active_wardrobe() if character else {}
if suppress_wardrobe:
wardrobe_source = {}
combined_data = { combined_data = {
'character_id': character.character_id if character else 'unknown', 'character_id': character.character_id if character else 'unknown',
@@ -88,7 +119,6 @@ def generate_from_preset(preset, overrides=None):
'styles': character.data.get('styles', {}) if character else {}, 'styles': character.data.get('styles', {}) if character else {},
'lora': (look_obj.data.get('lora', {}) if look_obj 'lora': (look_obj.data.get('lora', {}) if look_obj
else (character.data.get('lora', {}) if character else {})), else (character.data.get('lora', {}) if character else {})),
'tags': (character.data.get('tags', []) if character else []) + data.get('tags', []),
} }
# Build extras prompt from secondary resources # Build extras prompt from secondary resources
@@ -108,7 +138,6 @@ def generate_from_preset(preset, overrides=None):
trg = action_obj.data.get('lora', {}).get('lora_triggers', '') trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
if trg: if trg:
extras_parts.append(trg) extras_parts.append(trg)
extras_parts.extend(action_obj.data.get('tags', []))
if style_obj: if style_obj:
s = style_obj.data.get('style', {}) s = style_obj.data.get('style', {})
if s.get('artist_name'): if s.get('artist_name'):
@@ -133,7 +162,6 @@ def generate_from_preset(preset, overrides=None):
trg = scene_obj.data.get('lora', {}).get('lora_triggers', '') trg = scene_obj.data.get('lora', {}).get('lora_triggers', '')
if trg: if trg:
extras_parts.append(trg) extras_parts.append(trg)
extras_parts.extend(scene_obj.data.get('tags', []))
if detailer_obj: if detailer_obj:
prompt_val = detailer_obj.data.get('prompt', '') prompt_val = detailer_obj.data.get('prompt', '')
if isinstance(prompt_val, list): if isinstance(prompt_val, list):
@@ -195,6 +223,7 @@ def generate_from_preset(preset, overrides=None):
) )
label = f"Preset: {preset.name} {action}" label = f"Preset: {preset.name} {action}"
job = _enqueue_job(label, workflow, _make_finalize('presets', preset.slug, Preset, action)) db_model = Preset if save_category == 'presets' else None
job = _enqueue_job(label, workflow, _make_finalize(save_category, preset.slug, db_model, action, metadata=resolved_meta))
return job return job

View File

@@ -1,3 +1,4 @@
import json
import os import os
import time import time
import uuid import uuid
@@ -27,8 +28,10 @@ logger = logging.getLogger('gaze')
_job_queue_lock = threading.Lock() _job_queue_lock = threading.Lock()
_job_queue = deque() # ordered list of job dicts (pending + paused + processing) _job_queue = deque() # ordered list of job dicts (pending + paused + processing)
_llm_queue = deque() # ordered list of LLM task dicts (pending + paused + processing)
_job_history = {} # job_id -> job dict (all jobs ever added, for status lookup) _job_history = {} # job_id -> job dict (all jobs ever added, for status lookup)
_queue_worker_event = threading.Event() # signals worker that a new job is available _queue_worker_event = threading.Event() # signals worker that a new job is available
_llm_worker_event = threading.Event() # signals LLM worker that a new task is available
# Stored reference to the Flask app, set by init_queue_worker() # Stored reference to the Flask app, set by init_queue_worker()
_app = None _app = None
@@ -39,6 +42,7 @@ def _enqueue_job(label, workflow, finalize_fn):
job = { job = {
'id': str(uuid.uuid4()), 'id': str(uuid.uuid4()),
'label': label, 'label': label,
'job_type': 'comfyui',
'status': 'pending', 'status': 'pending',
'workflow': workflow, 'workflow': workflow,
'finalize_fn': finalize_fn, 'finalize_fn': finalize_fn,
@@ -55,6 +59,26 @@ def _enqueue_job(label, workflow, finalize_fn):
return job return job
def _enqueue_task(label, task_fn):
"""Add a generic task job (e.g. LLM call) to the LLM queue. Returns the job dict."""
job = {
'id': str(uuid.uuid4()),
'label': label,
'job_type': 'llm',
'status': 'pending',
'task_fn': task_fn,
'error': None,
'result': None,
'created_at': time.time(),
}
with _job_queue_lock:
_llm_queue.append(job)
_job_history[job['id']] = job
logger.info("LLM task queued: [%s] %s", job['id'][:8], label)
_llm_worker_event.set()
return job
def _queue_worker(): def _queue_worker():
"""Background thread: processes jobs from _job_queue sequentially.""" """Background thread: processes jobs from _job_queue sequentially."""
while True: while True:
@@ -174,13 +198,14 @@ def _queue_worker():
_prune_job_history() _prune_job_history()
def _make_finalize(category, slug, db_model_class=None, action=None): def _make_finalize(category, slug, db_model_class=None, action=None, metadata=None):
"""Return a finalize callback for a standard queue job. """Return a finalize callback for a standard queue job.
category — upload sub-directory name (e.g. 'characters', 'outfits') category — upload sub-directory name (e.g. 'characters', 'outfits')
slug — entity slug used for the upload folder name slug — entity slug used for the upload folder name
db_model_class — SQLAlchemy model class for cover-image DB update; None = skip db_model_class — SQLAlchemy model class for cover-image DB update; None = skip
action — 'replace' → update DB; None → always update; anything else → skip action — 'replace' → update DB; None → always update; anything else → skip
metadata — optional dict to write as JSON sidecar alongside the image
""" """
def _finalize(comfy_prompt_id, job): def _finalize(comfy_prompt_id, job):
logger.debug("=" * 80) logger.debug("=" * 80)
@@ -212,6 +237,14 @@ def _make_finalize(category, slug, db_model_class=None, action=None):
f.write(image_data) f.write(image_data)
logger.info("Image saved: %s (%d bytes)", full_path, len(image_data)) logger.info("Image saved: %s (%d bytes)", full_path, len(image_data))
# Write JSON sidecar with generation metadata (if provided)
if metadata is not None:
sidecar_name = filename.rsplit('.', 1)[0] + '.json'
sidecar_path = os.path.join(folder, sidecar_name)
with open(sidecar_path, 'w') as sf:
json.dump(metadata, sf)
logger.debug(" Sidecar written: %s", sidecar_path)
relative_path = f"{category}/{slug}/{filename}" relative_path = f"{category}/{slug}/{filename}"
# Include the seed used for this generation # Include the seed used for this generation
used_seed = job['workflow'].get('3', {}).get('inputs', {}).get('seed') used_seed = job['workflow'].get('3', {}).get('inputs', {}).get('seed')
@@ -244,6 +277,51 @@ def _make_finalize(category, slug, db_model_class=None, action=None):
return _finalize return _finalize
def _llm_queue_worker():
"""Background thread: processes LLM task jobs sequentially."""
while True:
_llm_worker_event.wait()
_llm_worker_event.clear()
while True:
job = None
with _job_queue_lock:
for j in _llm_queue:
if j['status'] == 'pending':
job = j
break
if job is None:
break
with _job_queue_lock:
job['status'] = 'processing'
logger.info("LLM task started: [%s] %s", job['id'][:8], job['label'])
try:
with _app.app_context():
job['task_fn'](job)
with _job_queue_lock:
job['status'] = 'done'
logger.info("LLM task completed: [%s] %s", job['id'][:8], job['label'])
except Exception as e:
logger.exception("LLM task failed: [%s] %s%s", job['id'][:8], job['label'], e)
with _job_queue_lock:
job['status'] = 'failed'
job['error'] = str(e)
with _job_queue_lock:
try:
_llm_queue.remove(job)
except ValueError:
pass
_prune_job_history()
def _prune_job_history(max_age_seconds=3600): def _prune_job_history(max_age_seconds=3600):
"""Remove completed/failed jobs older than max_age_seconds from _job_history.""" """Remove completed/failed jobs older than max_age_seconds from _job_history."""
cutoff = time.time() - max_age_seconds cutoff = time.time() - max_age_seconds
@@ -261,5 +339,5 @@ def init_queue_worker(flask_app):
""" """
global _app global _app
_app = flask_app _app = flask_app
worker = threading.Thread(target=_queue_worker, daemon=True, name='queue-worker') threading.Thread(target=_queue_worker, daemon=True, name='comfyui-worker').start()
worker.start() threading.Thread(target=_llm_queue_worker, daemon=True, name='llm-worker').start()

View File

@@ -2,7 +2,7 @@ import os
import json import json
import asyncio import asyncio
import requests import requests
from flask import request as flask_request from flask import has_request_context, request as flask_request
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
from models import Settings from models import Settings
@@ -77,6 +77,28 @@ def call_mcp_tool(name, arguments):
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
async def _run_character_mcp_tool(name, arguments):
server_params = StdioServerParameters(
command="docker",
args=["run", "--rm", "-i",
"-v", "character-cache:/root/.local/share/character_details",
"character-mcp:latest"],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(name, arguments)
return result.content[0].text
def call_character_mcp_tool(name, arguments):
try:
return asyncio.run(_run_character_mcp_tool(name, arguments))
except Exception as e:
print(f"Character MCP Tool Error: {e}")
return None
def load_prompt(filename): def load_prompt(filename):
path = os.path.join('data/prompts', filename) path = os.path.join('data/prompts', filename)
if os.path.exists(path): if os.path.exists(path):
@@ -100,7 +122,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
headers = { headers = {
"Authorization": f"Bearer {settings.openrouter_api_key}", "Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
"HTTP-Referer": flask_request.url_root, "HTTP-Referer": flask_request.url_root if has_request_context() else "http://localhost:5000/",
"X-Title": "Character Browser" "X-Title": "Character Browser"
} }
model = settings.openrouter_model or 'google/gemini-2.0-flash-001' model = settings.openrouter_model or 'google/gemini-2.0-flash-001'
@@ -120,7 +142,8 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
{"role": "user", "content": prompt} {"role": "user", "content": prompt}
] ]
max_turns = 10 max_turns = 15
tool_turns_remaining = 8 # stop offering tools after this many tool-calling turns
use_tools = True use_tools = True
format_retries = 3 # retries allowed for unexpected response format format_retries = 3 # retries allowed for unexpected response format
@@ -131,13 +154,13 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
"messages": messages, "messages": messages,
} }
# Only add tools if supported/requested # Only add tools if supported/requested and we haven't exhausted tool turns
if use_tools: if use_tools and tool_turns_remaining > 0:
data["tools"] = DANBOORU_TOOLS data["tools"] = DANBOORU_TOOLS
data["tool_choice"] = "auto" data["tool_choice"] = "auto"
try: try:
response = requests.post(url, headers=headers, json=data) response = requests.post(url, headers=headers, json=data, timeout=120)
# If 400 Bad Request and we were using tools, try once without tools # If 400 Bad Request and we were using tools, try once without tools
if response.status_code == 400 and use_tools: if response.status_code == 400 and use_tools:
@@ -158,6 +181,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
raise KeyError('message') raise KeyError('message')
if message.get('tool_calls'): if message.get('tool_calls'):
tool_turns_remaining -= 1
messages.append(message) messages.append(message)
for tool_call in message['tool_calls']: for tool_call in message['tool_calls']:
name = tool_call['function']['name'] name = tool_call['function']['name']
@@ -170,6 +194,8 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
"name": name, "name": name,
"content": tool_result "content": tool_result
}) })
if tool_turns_remaining <= 0:
print("Tool turn limit reached — next request will not offer tools")
continue continue
return message['content'] return message['content']

View File

@@ -171,10 +171,6 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
if style_data.get('artistic_style') and is_selected('style', 'artistic_style'): if style_data.get('artistic_style') and is_selected('style', 'artistic_style'):
parts.append(style_data['artistic_style']) parts.append(style_data['artistic_style'])
tags = data.get('tags', [])
if tags and is_selected('special', 'tags'):
parts.extend(tags)
lora = data.get('lora', {}) lora = data.get('lora', {})
if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'): if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'):
parts.append(lora.get('lora_triggers')) parts.append(lora.get('lora_triggers'))
@@ -283,7 +279,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
lora = data.get('lora', {}) lora = data.get('lora', {})
if lora.get('lora_triggers'): if lora.get('lora_triggers'):
parts.append(lora['lora_triggers']) parts.append(lora['lora_triggers'])
parts.extend(data.get('tags', []))
for key in _BODY_GROUP_KEYS: for key in _BODY_GROUP_KEYS:
val = data.get('action', {}).get(key) val = data.get('action', {}).get(key)
if val: if val:
@@ -299,7 +294,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
lora = data.get('lora', {}) lora = data.get('lora', {})
if lora.get('lora_triggers'): if lora.get('lora_triggers'):
parts.append(lora['lora_triggers']) parts.append(lora['lora_triggers'])
parts.extend(data.get('tags', []))
for scene in scenes: for scene in scenes:
data = scene.data data = scene.data
@@ -311,7 +305,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
lora = data.get('lora', {}) lora = data.get('lora', {})
if lora.get('lora_triggers'): if lora.get('lora_triggers'):
parts.append(lora['lora_triggers']) parts.append(lora['lora_triggers'])
parts.extend(data.get('tags', []))
for style in styles: for style in styles:
data = style.data data = style.data

View File

@@ -14,6 +14,13 @@ from models import (
logger = logging.getLogger('gaze') logger = logging.getLogger('gaze')
def _sync_nsfw_from_tags(entity, data):
"""Sync is_nsfw from data['tags']['nsfw'] if tags is a dict. Never touches is_favourite."""
tags = data.get('tags')
if isinstance(tags, dict):
entity.is_nsfw = bool(tags.get('nsfw', False))
def sync_characters(): def sync_characters():
if not os.path.exists(current_app.config['CHARACTERS_DIR']): if not os.path.exists(current_app.config['CHARACTERS_DIR']):
return return
@@ -44,6 +51,7 @@ def sync_characters():
character.name = name character.name = name
character.slug = slug character.slug = slug
character.filename = filename character.filename = filename
_sync_nsfw_from_tags(character, data)
# Check if cover image still exists # Check if cover image still exists
if character.image_path: if character.image_path:
@@ -62,6 +70,7 @@ def sync_characters():
name=name, name=name,
data=data data=data
) )
_sync_nsfw_from_tags(new_char, data)
db.session.add(new_char) db.session.add(new_char)
except Exception as e: except Exception as e:
print(f"Error importing {filename}: {e}") print(f"Error importing {filename}: {e}")
@@ -102,6 +111,7 @@ def sync_outfits():
outfit.name = name outfit.name = name
outfit.slug = slug outfit.slug = slug
outfit.filename = filename outfit.filename = filename
_sync_nsfw_from_tags(outfit, data)
# Check if cover image still exists # Check if cover image still exists
if outfit.image_path: if outfit.image_path:
@@ -120,6 +130,7 @@ def sync_outfits():
name=name, name=name,
data=data data=data
) )
_sync_nsfw_from_tags(new_outfit, data)
db.session.add(new_outfit) db.session.add(new_outfit)
except Exception as e: except Exception as e:
print(f"Error importing outfit {filename}: {e}") print(f"Error importing outfit {filename}: {e}")
@@ -243,6 +254,7 @@ def sync_looks():
look.slug = slug look.slug = slug
look.filename = filename look.filename = filename
look.character_id = character_id look.character_id = character_id
_sync_nsfw_from_tags(look, data)
if look.image_path: if look.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], look.image_path) full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], look.image_path)
@@ -259,6 +271,7 @@ def sync_looks():
character_id=character_id, character_id=character_id,
data=data data=data
) )
_sync_nsfw_from_tags(new_look, data)
db.session.add(new_look) db.session.add(new_look)
except Exception as e: except Exception as e:
print(f"Error importing look {filename}: {e}") print(f"Error importing look {filename}: {e}")
@@ -418,6 +431,7 @@ def sync_actions():
action.name = name action.name = name
action.slug = slug action.slug = slug
action.filename = filename action.filename = filename
_sync_nsfw_from_tags(action, data)
# Check if cover image still exists # Check if cover image still exists
if action.image_path: if action.image_path:
@@ -435,6 +449,7 @@ def sync_actions():
name=name, name=name,
data=data data=data
) )
_sync_nsfw_from_tags(new_action, data)
db.session.add(new_action) db.session.add(new_action)
except Exception as e: except Exception as e:
print(f"Error importing action {filename}: {e}") print(f"Error importing action {filename}: {e}")
@@ -475,6 +490,7 @@ def sync_styles():
style.name = name style.name = name
style.slug = slug style.slug = slug
style.filename = filename style.filename = filename
_sync_nsfw_from_tags(style, data)
# Check if cover image still exists # Check if cover image still exists
if style.image_path: if style.image_path:
@@ -492,6 +508,7 @@ def sync_styles():
name=name, name=name,
data=data data=data
) )
_sync_nsfw_from_tags(new_style, data)
db.session.add(new_style) db.session.add(new_style)
except Exception as e: except Exception as e:
print(f"Error importing style {filename}: {e}") print(f"Error importing style {filename}: {e}")
@@ -532,6 +549,7 @@ def sync_detailers():
detailer.name = name detailer.name = name
detailer.slug = slug detailer.slug = slug
detailer.filename = filename detailer.filename = filename
_sync_nsfw_from_tags(detailer, data)
# Check if cover image still exists # Check if cover image still exists
if detailer.image_path: if detailer.image_path:
@@ -549,6 +567,7 @@ def sync_detailers():
name=name, name=name,
data=data data=data
) )
_sync_nsfw_from_tags(new_detailer, data)
db.session.add(new_detailer) db.session.add(new_detailer)
except Exception as e: except Exception as e:
print(f"Error importing detailer {filename}: {e}") print(f"Error importing detailer {filename}: {e}")
@@ -589,6 +608,7 @@ def sync_scenes():
scene.name = name scene.name = name
scene.slug = slug scene.slug = slug
scene.filename = filename scene.filename = filename
_sync_nsfw_from_tags(scene, data)
# Check if cover image still exists # Check if cover image still exists
if scene.image_path: if scene.image_path:
@@ -606,6 +626,7 @@ def sync_scenes():
name=name, name=name,
data=data data=data
) )
_sync_nsfw_from_tags(new_scene, data)
db.session.add(new_scene) db.session.add(new_scene)
except Exception as e: except Exception as e:
print(f"Error importing scene {filename}: {e}") print(f"Error importing scene {filename}: {e}")
@@ -679,19 +700,22 @@ def sync_checkpoints():
ckpt.slug = slug ckpt.slug = slug
ckpt.checkpoint_path = checkpoint_path ckpt.checkpoint_path = checkpoint_path
ckpt.data = data ckpt.data = data
_sync_nsfw_from_tags(ckpt, data)
flag_modified(ckpt, "data") flag_modified(ckpt, "data")
if ckpt.image_path: if ckpt.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], ckpt.image_path) full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], ckpt.image_path)
if not os.path.exists(full_img_path): if not os.path.exists(full_img_path):
ckpt.image_path = None ckpt.image_path = None
else: else:
db.session.add(Checkpoint( new_ckpt = Checkpoint(
checkpoint_id=checkpoint_id, checkpoint_id=checkpoint_id,
slug=slug, slug=slug,
name=display_name, name=display_name,
checkpoint_path=checkpoint_path, checkpoint_path=checkpoint_path,
data=data, data=data,
)) )
_sync_nsfw_from_tags(new_ckpt, data)
db.session.add(new_ckpt)
all_ckpts = Checkpoint.query.all() all_ckpts = Checkpoint.query.all()
for ckpt in all_ckpts: for ckpt in all_ckpts:

View File

@@ -10,23 +10,23 @@
<form action="{{ url_for('create_action') }}" method="post"> <form action="{{ url_for('create_action') }}" method="post">
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Action Name</label> <label for="name" class="form-label">Action Name</label>
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Belly Dancing" required> <input type="text" class="form-control" id="name" name="name" placeholder="e.g. Belly Dancing" value="{{ form_data.get('name', '') }}" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label> <label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. belly_dancing"> <input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. belly_dancing" value="{{ form_data.get('filename', '') }}">
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div> <div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
</div> </div>
<div class="mb-3 form-check form-switch"> <div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked> <input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label> <label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
</div> </div>
<div class="mb-3" id="prompt-group"> <div class="mb-3" id="prompt-group">
<label for="prompt" class="form-label">Description / Concept</label> <label for="prompt" class="form-label">Description / Concept</label>
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the action, pose, or movement. The AI will generate the full action profile based on this."></textarea> <textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the action, pose, or movement. The AI will generate the full action profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
<div class="form-text">Required when AI generation is enabled.</div> <div class="form-text">Required when AI generation is enabled.</div>
</div> </div>

View File

@@ -111,33 +111,29 @@
</div> </div>
</div> </div>
{% set tags = action.data.tags if action.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white"><span>Tags</span></div>
<span>Tags</span>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
{% if preferences is not none %}
{% if 'special::tags' in preferences %}checked{% endif %}
{% elif action.default_fields is not none %}
{% if 'special::tags' in action.default_fields %}checked{% endif %}
{% endif %}>
<label class="form-check-label text-white small {% if action.default_fields is not none and 'special::tags' in action.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
</div>
</div>
<div class="card-body"> <div class="card-body">
{% for tag in action.data.tags %} {% if tags.participants %}<span class="badge bg-info">{{ tags.participants }}</span>{% endif %}
<span class="badge bg-secondary">{{ tag }}</span> {% if action.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% else %} {% if action.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
<span class="text-muted">No tags</span>
{% endfor %}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">{{ action.name }}</h1> <h1 class="mb-0">
{{ action.name }}
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/action/{{ action.slug }}/favourite" title="Toggle favourite">
<span style="font-size:1.2rem;">{% if action.is_favourite %}&#9733;{% else %}&#9734;{% endif %}</span>
</button>
{% if action.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h1>
<a href="{{ url_for('edit_action', slug=action.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a> <a href="{{ url_for('edit_action', slug=action.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
<form action="{{ url_for('clone_action', slug=action.slug) }}" method="post" style="display: inline;"> <form action="{{ url_for('clone_action', slug=action.slug) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Action</button> <button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Action</button>
@@ -145,6 +141,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('actions', '{{ action.slug }}')">Regenerate Tags</button>
<a href="{{ url_for('transfer_resource', category='actions', slug=action.slug) }}" class="btn btn-outline-primary">Transfer</a> <a href="{{ url_for('transfer_resource', category='actions', slug=action.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a> <a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div> </div>
@@ -299,6 +296,16 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form'); const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');

View File

@@ -22,9 +22,24 @@
<label for="action_id" class="form-label">Action ID</label> <label for="action_id" class="form-label">Action ID</label>
<input type="text" class="form-control" id="action_id" name="action_id" value="{{ action.action_id }}"> <input type="text" class="form-control" id="action_id" name="action_id" value="{{ action.action_id }}">
</div> </div>
<div class="mb-3"> {% set tags = action.data.tags if action.data.tags is mapping else {} %}
<label for="tags" class="form-label">Tags (comma separated)</label> <div class="row">
<input type="text" class="form-control" id="tags" name="tags" value="{{ action.data.tags | join(', ') }}"> <div class="col-md-6 mb-3">
<label for="tag_participants" class="form-label">Participants</label>
<input type="text" class="form-control" id="tag_participants" name="tag_participants" value="{{ tags.participants or '' }}" placeholder="e.g. solo, 1girl 1boy, 2girls">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if action.is_nsfw %}checked{% endif %}>
<label class="form-check-label" for="tag_nsfw">NSFW</label>
</div>
</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="suppress_wardrobe" name="suppress_wardrobe" {% if action.data.get('suppress_wardrobe') %}checked{% endif %}>
<label class="form-check-label" for="suppress_wardrobe">Suppress Wardrobe</label>
<div class="form-text">When enabled, no clothing/wardrobe prompts are injected during generation.</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> {{ library_toolbar(
<h2>Action Library</h2> title="Action",
<div class="d-flex gap-1 align-items-center"> category="actions",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_url=url_for('create_action'),
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_label="Action",
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents"> has_batch_gen=true,
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new action entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_regen_all=true,
</form> has_lora_create=true,
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents"> bulk_create_url=url_for('bulk_create_actions_from_loras'),
<input type="hidden" name="overwrite" value="true"> has_tags=true,
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all action metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL action LoRAs, consuming significant API credits and overwriting ALL existing action metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> regen_tags_category="actions",
</form> rescan_url=url_for('rescan_actions'),
<a href="{{ url_for('create_action') }}" class="btn btn-sm btn-success">Create New Action</a> get_missing_url="/get_missing_actions",
<form action="{{ url_for('rescan_actions') }}" method="post" class="d-contents"> clear_covers_url="/clear_all_action_covers",
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan action files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button> generate_url_pattern="/action/{slug}/generate"
</form> ) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div> </div>
</div> <select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for action in actions %} {% for action in actions %}
@@ -40,7 +52,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ action.name }}</h5> <h5 class="card-title text-center">{% if action.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ action.name }}{% if action.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
<p class="card-text small text-center text-muted"> <p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %} {% set ns = namespace(parts=[]) %}
{% if action.data.action is mapping %} {% if action.data.action is mapping %}
@@ -80,111 +92,11 @@
} }
</style> </style>
<script> <script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight'); const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) { if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`); const card = document.getElementById(`card-${highlightSlug}`);
if (card) { if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress');
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
async function runBatch() {
const response = await fetch('/get_missing_actions');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert("No actions missing cover images.");
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
// Phase 1: Queue all jobs upfront
const jobs = [];
for (const item of missing) {
try {
const genResp = await fetch(`/action/${item.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
// Phase 2: Poll all concurrently
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
itemNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none');
}
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
}));
batchBtn.disabled = false;
regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} action images processed.`);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_actions');
const data = await response.json();
if (data.missing.length === 0) {
alert("No actions missing cover images.");
return;
}
if (!confirm(`Generate cover images for ${data.missing.length} actions?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm("This will unassign ALL current action cover images and generate new ones. Proceed?")) return;
const clearResp = await fetch('/clear_all_action_covers', { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script> </script>
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -106,7 +106,12 @@
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">{{ ckpt.name }}</h1> <h1 class="mb-0">
{{ ckpt.name }}
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/checkpoint/{{ ckpt.slug }}/favourite" title="Toggle favourite">
<span style="font-size:1.2rem;">{% if ckpt.is_favourite %}&#9733;{% else %}&#9734;{% endif %}</span>
</button>
</h1>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
@@ -232,6 +237,16 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form'); const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');

View File

@@ -1,23 +1,34 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> {% from "partials/library_toolbar.html" import library_toolbar %}
<h2>Checkpoint Library</h2> {{ library_toolbar(
<div class="d-flex gap-1 align-items-center"> title="Checkpoint",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> category="checkpoints",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_url=none,
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents"> has_batch_gen=true,
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new checkpoint entries from all checkpoint files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_regen_all=true,
</form> has_lora_create=true,
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents"> bulk_create_url=url_for('bulk_create_checkpoints'),
<input type="hidden" name="overwrite" value="true"> has_tags=false,
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all checkpoint metadata (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL checkpoints, consuming API credits and overwriting ALL existing metadata. Are you sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> rescan_url=url_for('rescan_checkpoints'),
</form> get_missing_url="/get_missing_checkpoints",
<form action="{{ url_for('rescan_checkpoints') }}" method="post" class="d-contents"> clear_covers_url="/clear_all_checkpoint_covers",
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan checkpoint files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button> generate_url_pattern="/checkpoint/{slug}/generate"
</form> ) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div> </div>
</div> <select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for ckpt in checkpoints %} {% for ckpt in checkpoints %}
@@ -39,7 +50,10 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ ckpt.name }}</h5> <h5 class="card-title text-center">
{% if ckpt.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ ckpt.name }}
{% if ckpt.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h5>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center p-1"> <div class="card-footer d-flex justify-content-between align-items-center p-1">
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small> <small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
@@ -53,101 +67,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
document.addEventListener('DOMContentLoaded', () => {
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const ckptNameText = document.getElementById('current-ckpt-name');
const stepProgressText = document.getElementById('current-step-progress');
let currentJobId = null;
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
async function runBatch() {
const response = await fetch('/get_missing_checkpoints');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert('No checkpoints missing cover images.');
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
// Phase 1: Queue all jobs upfront
const jobs = [];
for (const ckpt of missing) {
try {
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ character_slug: '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item: ckpt, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${ckpt.name}:`, err);
}
}
// Phase 2: Poll all concurrently
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-${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 ${item.name}:`, err);
}
}));
batchBtn.disabled = false;
regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} checkpoint images processed.`);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_checkpoints');
const data = await response.json();
if (data.missing.length === 0) { alert('No checkpoints missing cover images.'); return; }
if (!confirm(`Generate cover images for ${data.missing.length} checkpoints?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm('This will unassign ALL current checkpoint cover images and generate new ones. Proceed?')) return;
const clearResp = await fetch('/clear_all_checkpoint_covers', { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -10,26 +10,32 @@
<form action="{{ url_for('create_character') }}" method="post"> <form action="{{ url_for('create_character') }}" method="post">
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Character Name</label> <label for="name" class="form-label">Character Name</label>
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Cyberpunk Ninja" required> <input type="text" class="form-control" id="name" name="name" placeholder="e.g. Cyberpunk Ninja" value="{{ form_data.get('name', '') }}" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label> <label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. cyberpunk_ninja"> <input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. cyberpunk_ninja" value="{{ form_data.get('filename', '') }}">
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div> <div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
</div> </div>
<div class="mb-3 form-check form-switch"> <div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked> <input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label> <label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
</div> </div>
<div class="mb-3" id="prompt-group"> <div class="mb-3" id="prompt-group">
<label for="prompt" class="form-label">Description / Concept</label> <label for="prompt" class="form-label">Description / Concept</label>
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the character's appearance, clothing, style, and personality. The AI will generate the full profile based on this."></textarea> <textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the character's appearance, clothing, style, and personality. The AI will generate the full profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
<div class="form-text">Required when AI generation is enabled.</div> <div class="form-text">Required when AI generation is enabled.</div>
</div> </div>
<div class="mb-3" id="wiki-url-group">
<label for="wiki_url" class="form-label">Wiki / Reference URL <small class="text-muted">- optional</small></label>
<input type="url" class="form-control" id="wiki_url" name="wiki_url" placeholder="e.g. https://finalfantasy.fandom.com/wiki/Tifa_Lockhart" value="{{ form_data.get('wiki_url', '') }}">
<div class="form-text">Fandom wiki URL or other character page. The AI will use this as reference for accurate appearance details.</div>
</div>
<div class="alert alert-info" id="ai-info"> <div class="alert alert-info" id="ai-info">
<i class="bi bi-info-circle"></i> The AI will generate a complete character profile based on your description. <i class="bi bi-info-circle"></i> The AI will generate a complete character profile based on your description.
</div> </div>
@@ -51,6 +57,7 @@
<script> <script>
document.getElementById('use_llm').addEventListener('change', function() { document.getElementById('use_llm').addEventListener('change', function() {
const promptGroup = document.getElementById('prompt-group'); const promptGroup = document.getElementById('prompt-group');
const wikiUrlGroup = document.getElementById('wiki-url-group');
const aiInfo = document.getElementById('ai-info'); const aiInfo = document.getElementById('ai-info');
const manualInfo = document.getElementById('manual-info'); const manualInfo = document.getElementById('manual-info');
const submitBtn = document.getElementById('submit-btn'); const submitBtn = document.getElementById('submit-btn');
@@ -58,12 +65,14 @@ document.getElementById('use_llm').addEventListener('change', function() {
if (this.checked) { if (this.checked) {
promptGroup.classList.remove('d-none'); promptGroup.classList.remove('d-none');
wikiUrlGroup.classList.remove('d-none');
aiInfo.classList.remove('d-none'); aiInfo.classList.remove('d-none');
manualInfo.classList.add('d-none'); manualInfo.classList.add('d-none');
submitBtn.textContent = 'Create & Generate'; submitBtn.textContent = 'Create & Generate';
promptInput.required = true; promptInput.required = true;
} else { } else {
promptGroup.classList.add('d-none'); promptGroup.classList.add('d-none');
wikiUrlGroup.classList.add('d-none');
aiInfo.classList.add('d-none'); aiInfo.classList.add('d-none');
manualInfo.classList.remove('d-none'); manualInfo.classList.remove('d-none');
submitBtn.textContent = 'Create Character'; submitBtn.textContent = 'Create Character';

View File

@@ -59,31 +59,30 @@
</div> </div>
</div> </div>
{% set tags = character.data.tags if character.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white"><span>Tags</span></div>
<span>Tags</span>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
{% if preferences is not none %}
{% if 'special::tags' in preferences %}checked{% endif %}
{% elif character.default_fields is not none %}
{% if 'special::tags' in character.default_fields %}checked{% endif %}
{% endif %}>
<label class="form-check-label text-white small {% if character.default_fields is not none and 'special::tags' in character.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
</div>
</div>
<div class="card-body"> <div class="card-body">
{% for tag in character.data.tags %} {% if tags.origin_series %}<span class="badge bg-info">{{ tags.origin_series }}</span>{% endif %}
<span class="badge bg-secondary">{{ tag }}</span> {% if tags.origin_type %}<span class="badge bg-primary">{{ tags.origin_type }}</span>{% endif %}
{% endfor %} {% if character.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if character.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="mb-0">{{ character.name }}</h1> <h1 class="mb-0">
{{ character.name }}
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/character/{{ character.slug }}/favourite" title="Toggle favourite">
<span style="font-size:1.2rem;">{% if character.is_favourite %}&#9733;{% else %}&#9734;{% endif %}</span>
</button>
{% if character.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h1>
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a> <a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
<a href="{{ url_for('transfer_character', slug=character.slug) }}" class="btn btn-sm btn-warning text-decoration-none"> <a href="{{ url_for('transfer_character', slug=character.slug) }}" class="btn btn-sm btn-warning text-decoration-none">
@@ -91,8 +90,11 @@
</a> </a>
</div> </div>
</div> </div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('characters', '{{ character.slug }}')">Regenerate Tags</button>
<a href="/" class="btn btn-outline-secondary">Back to Library</a> <a href="/" class="btn btn-outline-secondary">Back to Library</a>
</div> </div>
</div>
<ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist"> <ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@@ -271,6 +273,16 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form'); const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');

View File

@@ -10,13 +10,13 @@
<form action="{{ url_for('create_detailer') }}" method="post"> <form action="{{ url_for('create_detailer') }}" method="post">
<div class="mb-4"> <div class="mb-4">
<label for="name" class="form-label fw-bold">Detailer Name</label> <label for="name" class="form-label fw-bold">Detailer Name</label>
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Eye Detail Glossy" required> <input type="text" class="form-control" id="name" name="name" placeholder="e.g. Eye Detail Glossy" value="{{ form_data.get('name', '') }}" required>
<div class="form-text">The display name for the detailer gallery.</div> <div class="form-text">The display name for the detailer gallery.</div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="filename" class="form-label fw-bold">Detailer ID / Filename <small class="text-muted">(Optional)</small></label> <label for="filename" class="form-label fw-bold">Detailer ID / Filename <small class="text-muted">(Optional)</small></label>
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. eye_detail_glossy"> <input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. eye_detail_glossy" value="{{ form_data.get('filename', '') }}">
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div> <div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
</div> </div>

View File

@@ -127,13 +127,20 @@
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">{{ detailer.name }}</h1> <h1 class="mb-0">
{{ detailer.name }}
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/detailer/{{ detailer.slug }}/favourite" title="Toggle favourite">
<span style="font-size:1.2rem;">{% if detailer.is_favourite %}&#9733;{% else %}&#9734;{% endif %}</span>
</button>
{% if detailer.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h1>
<div class="mt-1"> <div class="mt-1">
<a href="{{ url_for('edit_detailer', slug=detailer.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Detailer</a> <a href="{{ url_for('edit_detailer', slug=detailer.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Detailer</a>
</div> </div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('detailers', '{{ detailer.slug }}')">Regenerate Tags</button>
<a href="{{ url_for('transfer_resource', category='detailers', slug=detailer.slug) }}" class="btn btn-outline-primary">Transfer</a> <a href="{{ url_for('transfer_resource', category='detailers', slug=detailer.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a> <a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div> </div>
@@ -260,6 +267,16 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form'); const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');

View File

@@ -74,11 +74,34 @@
value="{{ detailer.data.prompt or '' }}"> value="{{ detailer.data.prompt or '' }}">
<div class="form-text">Comma-separated tags, e.g. "glossy eyes, detailed irises"</div> <div class="form-text">Comma-separated tags, e.g. "glossy eyes, detailed irises"</div>
</div> </div>
<div class="mb-3"> {% set tags = detailer.data.tags if detailer.data.tags is mapping else {} %}
<label for="tags" class="form-label">Extra Tags</label> <div class="row">
<input type="text" class="form-control" id="tags" name="tags" <div class="col-md-4 mb-3">
value="{{ detailer.data.tags | join(', ') if detailer.data.tags else '' }}"> <label for="tag_associated_resource" class="form-label">Associated Resource</label>
<div class="form-text">Comma-separated extra tags appended to every generation.</div> <select class="form-select" id="tag_associated_resource" name="tag_associated_resource">
{% for opt in ['', 'General', 'Looks', 'Styles', 'Faces', 'NSFW'] %}
<option value="{{ opt }}" {% if tags.associated_resource == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-5 mb-3">
<label class="form-label">ADetailer Targets</label>
<div>
{% for target in ['face', 'hands', 'body', 'nsfw'] %}
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="tag_target_{{ target }}" name="tag_adetailer_targets" value="{{ target }}" {% if tags.adetailer_targets is defined and target in tags.adetailer_targets %}checked{% endif %}>
<label class="form-check-label" for="tag_target_{{ target }}">{{ target|capitalize }}</label>
</div>
{% endfor %}
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if detailer.is_nsfw %}checked{% endif %}>
<label class="form-check-label" for="tag_nsfw">NSFW</label>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> {{ library_toolbar(
<h2>Detailer Library</h2> title="Detailer",
<div class="d-flex gap-1 align-items-center"> category="detailers",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_url=url_for('create_detailer'),
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_label="Detailer",
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents"> has_batch_gen=true,
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new detailer entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_regen_all=true,
</form> has_lora_create=true,
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents"> bulk_create_url=url_for('bulk_create_detailers_from_loras'),
<input type="hidden" name="overwrite" value="true"> has_tags=true,
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all detailer metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL detailer LoRAs, consuming significant API credits and overwriting ALL existing detailer metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> regen_tags_category="detailers",
</form> rescan_url=url_for('rescan_detailers'),
<a href="{{ url_for('create_detailer') }}" class="btn btn-sm btn-success">Create New Detailer</a> get_missing_url="/get_missing_detailers",
<form action="{{ url_for('rescan_detailers') }}" method="post" class="d-contents"> clear_covers_url="/clear_all_detailer_covers",
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan detailer files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button> generate_url_pattern="/detailer/{slug}/generate"
</form> ) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div> </div>
</div> <select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for detailer in detailers %} {% for detailer in detailers %}
@@ -40,7 +52,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ detailer.name }}</h5> <h5 class="card-title text-center">{% if detailer.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ detailer.name }}{% if detailer.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
<p class="card-text small text-center text-muted"> <p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %} {% set ns = namespace(parts=[]) %}
{% if detailer.data.prompt is string %} {% if detailer.data.prompt is string %}
@@ -82,111 +94,11 @@
} }
</style> </style>
<script> <script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight'); const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) { if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`); const card = document.getElementById(`card-${highlightSlug}`);
if (card) { if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const detailerNameText = document.getElementById('current-detailer-name');
const stepProgressText = document.getElementById('current-step-progress');
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
async function runBatch() {
const response = await fetch('/get_missing_detailers');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert("No detailers missing cover images.");
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
// Phase 1: Queue all jobs upfront
const jobs = [];
for (const item of missing) {
try {
const genResp = await fetch(`/detailer/${item.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
// Phase 2: Poll all concurrently
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) {
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 ${item.name}:`, err);
}
}));
batchBtn.disabled = false;
regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} detailer images processed.`);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_detailers');
const data = await response.json();
if (data.missing.length === 0) {
alert("No detailers missing cover images.");
return;
}
if (!confirm(`Generate cover images for ${data.missing.length} detailers?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm("This will unassign ALL current detailer cover images and generate new ones. Proceed?")) return;
const clearResp = await fetch('/clear_all_detailer_covers', { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script> </script>
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -18,9 +18,27 @@
<label for="character_name" class="form-label">Display Name</label> <label for="character_name" class="form-label">Display Name</label>
<input type="text" class="form-control" id="character_name" name="character_name" value="{{ character.name }}" required> <input type="text" class="form-control" id="character_name" name="character_name" value="{{ character.name }}" required>
</div> </div>
<div class="mb-3"> {% set tags = character.data.tags if character.data.tags is mapping else {} %}
<label for="tags" class="form-label">Tags (comma separated)</label> <div class="row">
<input type="text" class="form-control" id="tags" name="tags" value="{{ character.data.tags | join(', ') }}"> <div class="col-md-6 mb-3">
<label for="tag_origin_series" class="form-label">Origin Series</label>
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
</div>
<div class="col-md-3 mb-3">
<label for="tag_origin_type" class="form-label">Origin Type</label>
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if character.is_nsfw %}checked{% endif %}>
<label class="form-check-label" for="tag_nsfw">NSFW</label>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -121,7 +139,7 @@
</button> </button>
</div> </div>
<div class="card-body"> <div class="card-body">
{% set wardrobe_data = character.data.wardrobe %} {% set wardrobe_data = character.data.get('wardrobe', {}) %}
{% set outfits = character.get_available_outfits() %} {% set outfits = character.get_available_outfits() %}
{% if wardrobe_data.default is defined and wardrobe_data.default is mapping %} {% if wardrobe_data.default is defined and wardrobe_data.default is mapping %}
{# New nested format - show tabs for each outfit #} {# New nested format - show tabs for each outfit #}

View File

@@ -57,23 +57,50 @@
</select> </select>
</div> </div>
<!-- Favourite filter -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">&nbsp;</label>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div>
</div>
<!-- NSFW filter -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Rating</label>
<select name="nsfw" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</div>
<!-- Active filter chips --> <!-- Active filter chips -->
<div class="col"> <div class="col">
{% if category != 'all' %} {% if category != 'all' %}
<span class="badge bg-primary me-1"> <span class="badge bg-primary me-1">
{{ category | capitalize }} {{ category | capitalize }}
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a> <a href="{{ url_for('gallery', sort=sort, per_page=per_page, xref_category=xref_category, xref_slug=xref_slug) }}" class="text-white ms-1 text-decoration-none">×</a>
</span> </span>
{% endif %} {% endif %}
{% if slug %} {% if slug %}
<span class="badge bg-secondary me-1"> <span class="badge bg-secondary me-1">
{{ slug }} {{ slug }}
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a> <a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page, xref_category=xref_category, xref_slug=xref_slug) }}" class="text-white ms-1 text-decoration-none">×</a>
</span>
{% endif %}
{% if xref_category and xref_slug %}
<span class="badge bg-info me-1">
Cross-ref: {{ xref_category | capitalize }} = {{ xref_slug }}
<a href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
</span> </span>
{% endif %} {% endif %}
</div> </div>
<input type="hidden" name="page" value="1"> <input type="hidden" name="page" value="1">
{% if xref_category %}<input type="hidden" name="xref_category" value="{{ xref_category }}">{% endif %}
{% if xref_slug %}<input type="hidden" name="xref_slug" value="{{ xref_slug }}">{% endif %}
</div> </div>
</form> </form>
@@ -117,6 +144,17 @@
class="badge {% if category == 'checkpoints' %}bg-dark{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"> class="badge {% if category == 'checkpoints' %}bg-dark{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
Checkpoints Checkpoints
</a> </a>
<span class="text-muted small mx-1">|</span>
<a href="{{ url_for('gallery', category='presets', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'presets' %}text-white{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"
{% if category == 'presets' %}style="background-color: #6f42c1"{% endif %}>
Presets
</a>
<a href="{{ url_for('gallery', category='generator', sort=sort, per_page=per_page) }}"
class="badge {% if category == 'generator' %}text-white{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"
{% if category == 'generator' %}style="background-color: #20c997"{% endif %}>
Generator
</a>
</div> </div>
</div> </div>
@@ -222,6 +260,9 @@
'styles': 'warning', 'styles': 'warning',
'detailers': 'secondary', 'detailers': 'secondary',
'checkpoints': 'dark', 'checkpoints': 'dark',
'looks': 'primary',
'presets': 'purple',
'generator': 'teal',
} %} } %}
{% for img in images %} {% for img in images %}
<div class="gallery-card" <div class="gallery-card"
@@ -237,6 +278,8 @@
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}"> <span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
{{ img.category[:-1] if img.category.endswith('s') else img.category }} {{ img.category[:-1] if img.category.endswith('s') else img.category }}
</span> </span>
{% if img._sidecar.get('is_favourite') %}<span class="fav-badge" title="Favourite" onclick="event.stopPropagation(); toggleImageFavourite('{{ img.path }}', this)">&#9733;</span>{% else %}<span class="fav-badge fav-off" title="Mark as favourite" onclick="event.stopPropagation(); toggleImageFavourite('{{ img.path }}', this)">&#9734;</span>{% endif %}
{% if img._sidecar.get('is_nsfw') %}<span class="nsfw-badge badge bg-danger" style="position:absolute;top:4px;right:4px;font-size:0.6rem;">NSFW</span>{% endif %}
<!-- Info View Additional Metadata --> <!-- Info View Additional Metadata -->
<div class="info-meta"> <div class="info-meta">
@@ -280,8 +323,12 @@
<a href="{{ url_for('checkpoint_detail', slug=img.slug) }}" <a href="{{ url_for('checkpoint_detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2" class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Open</a> onclick="event.stopPropagation()">Open</a>
{% elif img.category in ('presets', 'generator') %}
<a href="{{ url_for('preset_detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Preset</a>
{% else %} {% else %}
<a href="{{ url_for('generator') }}?{{ img.category[:-1] }}={{ img.slug }}" <a href="{{ url_for('generator') }}?preset={{ img.slug }}"
class="btn btn-sm btn-outline-light py-0 px-2" class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Generator</a> onclick="event.stopPropagation()">Generator</a>
{% endif %} {% endif %}
@@ -376,6 +423,12 @@
<div class="meta-grid" id="metaGrid"></div> <div class="meta-grid" id="metaGrid"></div>
</div> </div>
<!-- Cross-reference entity chips (from sidecar) -->
<div class="mb-3 d-none" id="xrefRow">
<label class="form-label fw-semibold mb-1">Used Entities</label>
<div class="d-flex flex-wrap gap-1" id="xrefContainer"></div>
</div>
<div id="noMetaMsg" class="d-none"> <div id="noMetaMsg" class="d-none">
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p> <p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
</div> </div>
@@ -409,7 +462,31 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<style>
.fav-badge { position: absolute; top: 4px; left: 4px; font-size: 1.2rem; color: #ffc107; cursor: pointer; text-shadow: 0 0 3px rgba(0,0,0,0.7); z-index: 2; }
.fav-badge.fav-off { color: rgba(255,255,255,0.5); }
.fav-badge:hover { transform: scale(1.2); }
</style>
<script> <script>
async function toggleImageFavourite(path, el) {
const resp = await fetch('/gallery/image/favourite', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path})
});
const data = await resp.json();
if (data.success) {
el.innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
el.classList.toggle('fav-off', !data.is_favourite);
}
}
async function toggleImageNsfw(path, el) {
const resp = await fetch('/gallery/image/nsfw', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path})
});
const data = await resp.json();
if (data.success) location.reload();
}
// ---- Prompt modal ---- // ---- Prompt modal ----
let promptModal; let promptModal;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -473,15 +550,55 @@ async function showPrompt(imgPath, name, category, slug) {
document.getElementById('noMetaMsg').classList.toggle('d-none', document.getElementById('noMetaMsg').classList.toggle('d-none',
!!(data.positive || loras.length || hasParams)); !!(data.positive || loras.length || hasParams));
// Cross-reference entity chips (from sidecar)
const xrefContainer = document.getElementById('xrefContainer');
const xrefRow = document.getElementById('xrefRow');
xrefContainer.innerHTML = '';
if (data.sidecar) {
const sc = data.sidecar;
const xrefColors = {
character: 'primary', outfit: 'success', action: 'danger',
style: 'warning', scene: 'info', detailer: 'secondary',
look: 'primary', preset: 'purple'
};
for (const [key, sidecarKey] of [
['character', 'character_slug'], ['outfit', 'outfit_slug'],
['action', 'action_slug'], ['style', 'style_slug'],
['scene', 'scene_slug'], ['detailer', 'detailer_slug'],
['look', 'look_slug'], ['preset', 'preset_slug']
]) {
const val = sc[sidecarKey];
if (!val) continue;
const chip = document.createElement('a');
chip.className = `badge bg-${xrefColors[key] || 'secondary'} text-decoration-none`;
chip.href = `/gallery?xref_category=${key}&xref_slug=${encodeURIComponent(val)}`;
chip.textContent = `${key}: ${sc[sidecarKey.replace('_slug', '_name')] || val}`;
chip.title = `Show all images using this ${key}`;
xrefContainer.appendChild(chip);
}
xrefRow.classList.toggle('d-none', xrefContainer.children.length === 0);
} else {
xrefRow.classList.add('d-none');
}
// Generator link // Generator link
const genUrl = category === 'characters' let genUrl, genLabel;
? `/character/${slug}` if (category === 'characters') {
: category === 'checkpoints' genUrl = `/character/${slug}`;
? `/checkpoint/${slug}` genLabel = 'Open';
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`; } else if (category === 'checkpoints') {
genUrl = `/checkpoint/${slug}`;
genLabel = 'Open';
} else if (category === 'presets' || category === 'generator') {
genUrl = `/generator?preset=${encodeURIComponent(slug)}`;
genLabel = 'Open in Generator';
} else {
genUrl = `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
genLabel = 'Open in Generator';
}
const genBtn = document.getElementById('openGeneratorBtn'); const genBtn = document.getElementById('openGeneratorBtn');
genBtn.href = genUrl; genBtn.href = genUrl;
genBtn.textContent = (category === 'characters' || category === 'checkpoints') ? 'Open' : 'Open in Generator'; genBtn.textContent = genLabel;
} catch (e) { } catch (e) {
document.getElementById('promptPositive').value = 'Error loading metadata.'; document.getElementById('promptPositive').value = 'Error loading metadata.';
} finally { } finally {

View File

@@ -6,15 +6,15 @@
<div class="col-md-5"> <div class="col-md-5">
<div id="progress-container" class="mb-3 d-none"> <div id="progress-container" class="mb-3 d-none">
<label id="progress-label" class="form-label">Generating...</label> <label id="progress-label" class="form-label">Generating...</label>
<div class="progress" role="progressbar" aria-label="Generation Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"> <div class="progress" role="progressbar">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div> <div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
</div> </div>
</div> </div>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-primary text-white">Generator Settings</div> <div class="card-header bg-primary text-white">Generator</div>
<div class="card-body"> <div class="card-body">
<form id="generator-form" action="{{ url_for('generator') }}" method="post"> <form id="generator-form" action="{{ url_for('generator_generate') }}" method="post">
<!-- Controls bar --> <!-- Controls bar -->
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom"> <div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
@@ -34,184 +34,94 @@
</div> </div>
</div> </div>
<!-- Character --> <!-- Preset selector -->
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label for="character" class="form-label mb-0">Character</label> <label for="preset-select" class="form-label mb-0 fw-semibold">Preset</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-char-btn">Random</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="random-preset-btn">Random</button>
</div> </div>
<select class="form-select" id="character" name="character" required> <select class="form-select" id="preset-select" name="preset_slug" required>
<option value="" disabled {% if not selected_char %}selected{% endif %}>Select a character...</option> <option value="" disabled {% if not preset_slug %}selected{% endif %}>Select a preset...</option>
{% for char in characters %} {% for p in presets %}
<option value="{{ char.slug }}" {% if selected_char == char.slug %}selected{% endif %}>{{ char.name }}</option> <option value="{{ p.slug }}" {% if preset_slug == p.slug %}selected{% endif %}>{{ p.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<!-- Checkpoint --> <!-- Preset summary (populated via AJAX) -->
<div class="mb-3" id="preset-summary-container" style="display:none">
<label class="form-label mb-1 small fw-semibold text-muted">Preset Configuration</label>
<div class="d-flex flex-wrap gap-1" id="preset-summary"></div>
</div>
<!-- Checkpoint override -->
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label for="checkpoint" class="form-label mb-0">Checkpoint Model</label> <label for="checkpoint" class="form-label mb-0">Checkpoint Override</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button>
</div> </div>
<select class="form-select" id="checkpoint" name="checkpoint" required> <select class="form-select form-select-sm" id="checkpoint" name="checkpoint">
<option value="">Use preset default</option>
{% for ckpt in checkpoints %} {% for ckpt in checkpoints %}
<option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt }}</option> <option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt.split('/')[-1] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-text">Listing models from Illustrious/ folder</div>
</div>
<!-- Mix & Match -->
<div class="mb-3">
<label class="form-label">Mix &amp; Match
<small class="text-muted fw-normal ms-1">— first checked per category applies its LoRA</small>
</label>
<div class="accordion" id="mixAccordion">
{% set mix_categories = [
('Actions', 'action', actions, 'action_slugs'),
('Outfits', 'outfit', outfits, 'outfit_slugs'),
('Scenes', 'scene', scenes, 'scene_slugs'),
('Styles', 'style', styles, 'style_slugs'),
('Detailers', 'detailer', detailers, 'detailer_slugs'),
] %}
{% for cat_label, cat_key, cat_items, field_name in mix_categories %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2" type="button"
data-bs-toggle="collapse" data-bs-target="#mix-{{ cat_key }}">
{{ cat_label }}
<span class="badge bg-secondary rounded-pill ms-2" id="badge-{{ cat_key }}">0</span>
<span class="badge bg-light text-secondary border ms-2 px-2 py-1"
style="cursor:pointer;font-size:.7rem;font-weight:normal"
onclick="event.stopPropagation(); randomizeCategory('{{ field_name }}', '{{ cat_key }}')">Random</span>
</button>
</h2>
<div id="mix-{{ cat_key }}" class="accordion-collapse collapse">
<div class="accordion-body p-2">
<input type="text" class="form-control form-control-sm mb-2"
placeholder="Search {{ cat_label | lower }}..."
oninput="filterMixCategory(this, 'mixlist-{{ cat_key }}')">
<div id="mixlist-{{ cat_key }}" style="max-height:220px;overflow-y:auto;">
{% for item in cat_items %}
<label class="mix-item d-flex align-items-center gap-2 px-2 py-1 rounded"
data-name="{{ item.name | lower }}" style="cursor:pointer;">
<input type="checkbox" class="form-check-input flex-shrink-0"
name="{{ field_name }}" value="{{ item.slug }}"
onchange="updateMixBadge('{{ cat_key }}', '{{ field_name }}')">
{% if item.image_path %}
<img src="{{ url_for('static', filename='uploads/' + item.image_path) }}"
class="rounded flex-shrink-0" style="width:32px;height:32px;object-fit:cover">
{% else %}
<span class="rounded bg-light flex-shrink-0 d-inline-flex align-items-center justify-content-center text-muted"
style="width:32px;height:32px;font-size:9px;">N/A</span>
{% endif %}
<span class="small text-truncate">{{ item.name }}</span>
</label>
{% else %}
<p class="text-muted small p-2 mb-0">No {{ cat_label | lower }} found.</p>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div> </div>
<!-- Resolution --> <!-- Resolution -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Resolution</label> <label class="form-label">Resolution Override</label>
<div class="d-flex flex-wrap gap-1 mb-2"> <div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-sm btn-secondary preset-btn" data-w="1024" data-h="1024">1:1</button> <button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="" data-h="">Preset default</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1152" data-h="896">4:3 L</button> <button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1024" data-h="1024">1:1</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="896" data-h="1152">4:3 P</button> <button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1152" data-h="896">4:3 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1344" data-h="768">16:9 L</button> <button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="896" data-h="1152">4:3 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1344">16:9 P</button> <button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1344" data-h="768">16:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1280" data-h="800">16:10 L</button> <button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="768" data-h="1344">16:9 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="800" data-h="1280">16:10 P</button> <button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1280" data-h="800">16:10 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1792" data-h="768">21:9 L</button> <button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="800" data-h="1280">16:10 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1792">21:9 P</button>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 small fw-semibold">W</label> <label class="form-label mb-0 small fw-semibold">W</label>
<input type="number" class="form-control form-control-sm" name="width" id="res-width" <input type="number" class="form-control form-control-sm" name="width" id="res-width"
value="1024" min="64" max="4096" step="64" style="width:88px"> value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
<span class="text-muted">×</span> <span class="text-muted">&times;</span>
<label class="form-label mb-0 small fw-semibold">H</label> <label class="form-label mb-0 small fw-semibold">H</label>
<input type="number" class="form-control form-control-sm" name="height" id="res-height" <input type="number" class="form-control form-control-sm" name="height" id="res-height"
value="1024" min="64" max="4096" step="64" style="width:88px"> value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
</div> </div>
</div> </div>
<!-- Prompt Preview --> <!-- Extra prompts -->
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1"> <label for="extra_positive" class="form-label">Extra Positive Prompt</label>
<label class="form-label mb-0">Prompt Preview</label> <textarea class="form-control form-control-sm" id="extra_positive" name="extra_positive" rows="2"
<div class="d-flex gap-1"> placeholder="Additional tags appended to the preset prompt"></textarea>
<button type="button" class="btn btn-sm btn-outline-primary" id="build-preview-btn">Build</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
</div>
</div>
<div class="tag-widget-container d-none" id="prompt-tags"></div>
<textarea class="form-control form-control-sm font-monospace" id="prompt-preview"
name="override_prompt" rows="5"
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
<div class="form-text" id="preview-status"></div>
</div>
<!-- ADetailer Prompt Previews -->
<div class="mb-3">
<label class="form-label mb-0">Face Detailer Prompt</label>
<div class="tag-widget-container d-none" id="face-tags"></div>
<textarea class="form-control form-control-sm font-monospace" id="face-preview"
name="override_face_prompt" rows="2"
placeholder="Auto-populated on Build — edit to override face detailer prompt."></textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label mb-0">Hand Detailer Prompt</label> <label for="extra_negative" class="form-label">Extra Negative Prompt</label>
<div class="tag-widget-container d-none" id="hand-tags"></div> <textarea class="form-control form-control-sm" id="extra_negative" name="extra_negative" rows="2"
<textarea class="form-control form-control-sm font-monospace" id="hand-preview" placeholder="Additional negative tags"></textarea>
name="override_hand_prompt" rows="2"
placeholder="Auto-populated on Build — edit to override hand detailer prompt."></textarea>
</div>
<!-- Additional prompts -->
<div class="mb-3">
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
<textarea class="form-control" id="positive_prompt" name="positive_prompt" rows="2" placeholder="e.g. sitting in a cafe, drinking coffee, daylight"></textarea>
</div>
<div class="mb-3">
<label for="negative_prompt" class="form-label">Additional Negative Prompt</label>
<textarea class="form-control" id="negative_prompt" name="negative_prompt" rows="2" placeholder="e.g. bad hands, extra digits"></textarea>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-7"> <div class="col-md-7">
<div class="card"> <div class="card">
<div class="card-header bg-dark text-white">Result</div> <div class="card-header bg-dark text-white">Result</div>
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container"> <div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container">
{% if generated_image %}
<div class="img-container w-100 h-100">
<img src="{{ url_for('static', filename='uploads/' + generated_image) }}" alt="Generated Result" class="img-fluid w-100" id="result-img">
</div>
{% else %}
<div class="text-center text-muted" id="placeholder-text"> <div class="text-center text-muted" id="placeholder-text">
<p>Select settings and click Generate</p> <p>Select a preset and click Generate</p>
</div> </div>
<div class="img-container w-100 h-100 d-none"> <div class="img-container w-100 h-100 d-none">
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img"> <img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
</div> </div>
{% endif %}
</div> </div>
<div class="card-footer d-none" id="result-footer"> <div class="card-footer d-none" id="result-footer">
<small class="text-muted">Saved to character gallery</small> <small class="text-muted">Saved to generator gallery</small>
</div> </div>
</div> </div>
</div> </div>
@@ -221,297 +131,194 @@
{% block scripts %} {% block scripts %}
<script> <script>
// --- Filtering --- // --- Preset summary ---
function filterMixCategory(input, listId) { async function loadPresetInfo(slug) {
const query = input.value.toLowerCase(); const container = document.getElementById('preset-summary-container');
document.querySelectorAll(`#${listId} .mix-item`).forEach(el => { const summary = document.getElementById('preset-summary');
el.style.display = el.dataset.name.includes(query) ? '' : 'none'; if (!slug) { container.style.display = 'none'; return; }
});
try {
const res = await fetch(`/generator/preset_info?slug=${encodeURIComponent(slug)}`);
const data = await res.json();
summary.innerHTML = '';
const colors = {
character: 'primary', outfit: 'success', action: 'danger',
style: 'warning', scene: 'info', detailer: 'secondary',
look: 'primary', checkpoint: 'dark', resolution: 'light text-dark'
};
for (const [key, val] of Object.entries(data)) {
if (val === null) continue;
const color = colors[key] || 'secondary';
const chip = document.createElement('span');
chip.className = `badge bg-${color} me-1 mb-1`;
chip.innerHTML = `<small class="opacity-75">${key}:</small> ${val}`;
summary.appendChild(chip);
} }
container.style.display = '';
function updateMixBadge(key, fieldName) { } catch (e) {
const count = document.querySelectorAll(`input[name="${fieldName}"]:checked`).length; container.style.display = 'none';
const badge = document.getElementById(`badge-${key}`);
badge.textContent = count;
badge.className = count > 0
? 'badge bg-primary rounded-pill ms-2'
: 'badge bg-secondary rounded-pill ms-2';
} }
}
// --- Randomizers (global so inline onclick can call them) --- // --- Resolution presets ---
function randomizeCategory(fieldName, catKey) { document.querySelectorAll('.res-btn').forEach(btn => {
const cbs = Array.from(document.querySelectorAll(`input[name="${fieldName}"]`));
cbs.forEach(cb => cb.checked = false);
if (cbs.length) cbs[Math.floor(Math.random() * cbs.length)].checked = true;
updateMixBadge(catKey, fieldName);
}
function applyLuckyDip() {
const charOpts = Array.from(document.getElementById('character').options).filter(o => o.value);
if (charOpts.length)
document.getElementById('character').value = charOpts[Math.floor(Math.random() * charOpts.length)].value;
const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
if (ckptOpts.length)
document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
const presets = Array.from(document.querySelectorAll('.preset-btn'));
if (presets.length) presets[Math.floor(Math.random() * presets.length)].click();
[['action_slugs', 'action'], ['outfit_slugs', 'outfit'], ['scene_slugs', 'scene'],
['style_slugs', 'style'], ['detailer_slugs', 'detailer']].forEach(([field, key]) => {
randomizeCategory(field, key);
});
clearTagWidgets('prompt-tags', 'prompt-preview');
clearTagWidgets('face-tags', 'face-preview');
clearTagWidgets('hand-tags', 'hand-preview');
document.getElementById('preview-status').textContent = '';
}
// --- Resolution presets ---
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w; document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h; document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.preset-btn').forEach(b => { document.querySelectorAll('.res-btn').forEach(b => {
b.classList.remove('btn-secondary'); b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary'); b.classList.add('btn-outline-secondary');
}); });
btn.classList.remove('btn-outline-secondary'); btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary'); btn.classList.add('btn-secondary');
}); });
}); });
['res-width', 'res-height'].forEach(id => { // Deselect res presets when manual input changes
['res-width', 'res-height'].forEach(id => {
document.getElementById(id).addEventListener('input', () => { document.getElementById(id).addEventListener('input', () => {
document.querySelectorAll('.preset-btn').forEach(b => { document.querySelectorAll('.res-btn').forEach(b => {
b.classList.remove('btn-secondary'); b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary'); b.classList.add('btn-outline-secondary');
}); });
}); });
}); });
// --- Tag Widget System --- // --- Seed ---
function populateTagWidgets(containerId, textareaId, promptStr) { document.getElementById('seed-clear-btn').addEventListener('click', () => {
const container = document.getElementById(containerId); document.getElementById('seed-input').value = '';
const textarea = document.getElementById(textareaId); });
container.innerHTML = '';
if (!promptStr || !promptStr.trim()) { // --- Random buttons ---
container.classList.add('d-none'); document.getElementById('random-preset-btn').addEventListener('click', () => {
return; const opts = Array.from(document.getElementById('preset-select').options).filter(o => o.value);
if (opts.length) {
const pick = opts[Math.floor(Math.random() * opts.length)];
document.getElementById('preset-select').value = pick.value;
loadPresetInfo(pick.value);
} }
});
const tags = promptStr.split(',').map(t => t.trim()).filter(Boolean); document.getElementById('random-ckpt-btn').addEventListener('click', () => {
tags.forEach(tag => { const opts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
const el = document.createElement('span'); if (opts.length) {
el.className = 'tag-widget active'; document.getElementById('checkpoint').value = opts[Math.floor(Math.random() * opts.length)].value;
el.textContent = tag;
el.dataset.tag = tag;
el.addEventListener('click', () => {
el.classList.toggle('active');
el.classList.toggle('inactive');
rebuildFromTags(containerId, textareaId);
});
container.appendChild(el);
});
container.classList.remove('d-none');
textarea.classList.add('d-none');
} }
});
function rebuildFromTags(containerId, textareaId) { // --- Lucky Dip ---
const container = document.getElementById(containerId); function applyLuckyDip() {
const textarea = document.getElementById(textareaId); document.getElementById('random-preset-btn').click();
const activeTags = Array.from(container.querySelectorAll('.tag-widget.active')) const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
.map(el => el.dataset.tag); if (ckptOpts.length)
textarea.value = activeTags.join(', '); document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
const resBtns = Array.from(document.querySelectorAll('.res-btn'));
if (resBtns.length) resBtns[Math.floor(Math.random() * resBtns.length)].click();
}
// --- Preset select change ---
document.getElementById('preset-select').addEventListener('change', (e) => {
loadPresetInfo(e.target.value);
});
// --- Generation loop ---
let stopRequested = false;
async function waitForJob(jobId) {
while (true) {
const res = await fetch(`/api/queue/${jobId}/status`);
const data = await res.json();
if (data.status === 'done') return data;
if (data.status === 'failed') throw new Error(data.error || 'Generation failed');
await new Promise(r => setTimeout(r, 1500));
} }
}
function clearTagWidgets(containerId, textareaId) { function setGeneratingState(active) {
const container = document.getElementById(containerId); document.getElementById('generate-btn').disabled = active;
const textarea = document.getElementById(textareaId); document.getElementById('endless-btn').classList.toggle('d-none', active);
container.innerHTML = ''; document.getElementById('stop-btn').classList.toggle('d-none', !active);
container.classList.add('d-none'); document.getElementById('progress-container').classList.toggle('d-none', !active);
textarea.classList.remove('d-none'); }
textarea.value = '';
function updateSeedFromResult(result) {
if (result && result.result && result.result.seed != null) {
document.getElementById('seed-input').value = result.result.seed;
} }
}
// --- Prompt preview --- async function runOne(label) {
async function buildPromptPreview() {
const charVal = document.getElementById('character').value;
const status = document.getElementById('preview-status');
if (!charVal) { status.textContent = 'Select a character first.'; return; }
status.textContent = 'Building...';
const formData = new FormData(document.getElementById('generator-form'));
try {
const resp = await fetch('/generator/preview_prompt', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) {
status.textContent = 'Error: ' + data.error;
} else {
document.getElementById('prompt-preview').value = data.prompt;
document.getElementById('face-preview').value = data.face || '';
document.getElementById('hand-preview').value = data.hand || '';
populateTagWidgets('prompt-tags', 'prompt-preview', data.prompt);
populateTagWidgets('face-tags', 'face-preview', data.face || '');
populateTagWidgets('hand-tags', 'hand-preview', data.hand || '');
status.textContent = 'Click tags to toggle — Clear to reset.';
}
} catch (err) {
status.textContent = 'Request failed.';
}
}
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
document.getElementById('clear-preview-btn').addEventListener('click', () => {
clearTagWidgets('prompt-tags', 'prompt-preview');
clearTagWidgets('face-tags', 'face-preview');
clearTagWidgets('hand-tags', 'hand-preview');
document.getElementById('preview-status').textContent = '';
});
// --- Main generation logic ---
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('generator-form');
const progressBar = document.getElementById('progress-bar');
const progressCont = document.getElementById('progress-container');
const progressLbl = document.getElementById('progress-label');
const generateBtn = document.getElementById('generate-btn');
const endlessBtn = document.getElementById('endless-btn');
const stopBtn = document.getElementById('stop-btn');
const numInput = document.getElementById('num-images');
const resultImg = document.getElementById('result-img');
const placeholder = document.getElementById('placeholder-text');
const resultFooter = document.getElementById('result-footer');
let currentJobId = null;
let stopRequested = false;
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLbl.textContent = 'Generating…';
else progressLbl.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
function setGeneratingState(active) {
generateBtn.disabled = active;
endlessBtn.disabled = active;
stopBtn.classList.toggle('d-none', !active);
if (!active) progressCont.classList.add('d-none');
}
async function runOne(label) {
if (document.getElementById('lucky-dip').checked) applyLuckyDip(); if (document.getElementById('lucky-dip').checked) applyLuckyDip();
progressCont.classList.remove('d-none'); const progressBar = document.getElementById('progress-bar');
progressBar.style.width = '100%'; progressBar.textContent = ''; progressBar.style.width = '100%';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); progressBar.textContent = label;
progressLbl.textContent = label; document.getElementById('progress-label').textContent = label;
const fd = new FormData(form); const form = document.getElementById('generator-form');
const formData = new FormData(form);
const resp = await fetch(form.action, { const res = await fetch(form.action, {
method: 'POST', body: fd, method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData,
}); });
const data = await resp.json(); const data = await res.json();
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
currentJobId = data.job_id; const result = await waitForJob(data.job_id);
progressLbl.textContent = 'Queued…';
const jobResult = await waitForJob(currentJobId); if (result.result && result.result.image_url) {
currentJobId = null; const img = document.getElementById('result-img');
img.src = result.result.image_url + '?t=' + Date.now();
if (jobResult.result && jobResult.result.image_url) { img.parentElement.classList.remove('d-none');
resultImg.src = jobResult.result.image_url; document.getElementById('placeholder-text')?.classList.add('d-none');
resultImg.parentElement.classList.remove('d-none'); document.getElementById('result-footer').classList.remove('d-none');
if (placeholder) placeholder.classList.add('d-none');
resultFooter.classList.remove('d-none');
}
updateSeedFromResult(jobResult.result);
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
async function runLoop(endless) { updateSeedFromResult(result);
const total = endless ? Infinity : (parseInt(numInput.value) || 1); return result;
}
async function runLoop(endless) {
stopRequested = false; stopRequested = false;
setGeneratingState(true); setGeneratingState(true);
let n = 0;
const total = endless ? Infinity : parseInt(document.getElementById('num-images').value) || 1;
let i = 0;
try { try {
while (!stopRequested && n < total) { while (i < total && !stopRequested) {
n++; i++;
const lbl = endless ? `Generating #${n} (endless)...` const label = endless
: total === 1 ? 'Starting...' ? `Generating (endless) #${i}...`
: `Generating ${n} / ${total}...`; : total > 1
await runOne(lbl); ? `Generating ${i} / ${total}...`
: 'Generating...';
await runOne(label);
} }
} catch (err) { } catch (e) {
console.error(err); alert('Generation error: ' + e.message);
alert('Generation failed: ' + err.message);
} finally { } finally {
setGeneratingState(false); setGeneratingState(false);
} }
} }
form.addEventListener('submit', (e) => { e.preventDefault(); runLoop(false); }); // --- Event listeners ---
endlessBtn.addEventListener('click', () => runLoop(true)); document.getElementById('generator-form').addEventListener('submit', (e) => {
stopBtn.addEventListener('click', () => { e.preventDefault();
stopRequested = true; runLoop(false);
progressLbl.textContent = 'Stopping after current image...'; });
});
document.getElementById('random-char-btn').addEventListener('click', () => { document.getElementById('endless-btn').addEventListener('click', () => runLoop(true));
const opts = Array.from(document.getElementById('character').options).filter(o => o.value); document.getElementById('stop-btn').addEventListener('click', () => { stopRequested = true; });
if (opts.length) {
document.getElementById('character').value = opts[Math.floor(Math.random() * opts.length)].value;
buildPromptPreview();
}
});
document.getElementById('random-ckpt-btn').addEventListener('click', () => { // --- Init: load preset info if pre-selected ---
const opts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value); document.addEventListener('DOMContentLoaded', () => {
if (opts.length) const presetSelect = document.getElementById('preset-select');
document.getElementById('checkpoint').value = opts[Math.floor(Math.random() * opts.length)].value; if (presetSelect.value) loadPresetInfo(presetSelect.value);
}); });
document.getElementById('character').addEventListener('change', buildPromptPreview);
// Pre-populate from gallery URL params (?action=slug, ?outfit=slug, etc.)
const urlParams = new URLSearchParams(window.location.search);
const catMap = {
action: { field: 'action_slugs', catKey: 'action' },
outfit: { field: 'outfit_slugs', catKey: 'outfit' },
scene: { field: 'scene_slugs', catKey: 'scene' },
style: { field: 'style_slugs', catKey: 'style' },
detailer: { field: 'detailer_slugs', catKey: 'detailer' },
};
let preselected = false;
for (const [param, { field, catKey }] of Object.entries(catMap)) {
const val = urlParams.get(param);
if (!val) continue;
const cb = document.querySelector(`input[name="${field}"][value="${CSS.escape(val)}"]`);
if (cb) {
cb.checked = true;
updateMixBadge(catKey, field);
// Expand the accordion panel
const panel = document.getElementById(`mix-${catKey}`);
if (panel) new bootstrap.Collapse(panel, { toggle: false }).show();
preselected = true;
}
}
if (preselected) buildPromptPreview();
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,17 +1,35 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> {% from "partials/library_toolbar.html" import library_toolbar %}
<h2>Character Library</h2> {{ library_toolbar(
<div class="d-flex gap-1 align-items-center"> title="Character",
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a> category="characters",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_url=url_for('create_character'),
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_label="Character",
<form action="{{ url_for('rescan') }}" method="post" class="d-contents"> has_batch_gen=true,
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan character files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button> has_regen_all=true,
</form> has_lora_create=false,
has_tags=true,
regen_tags_category="characters",
rescan_url=url_for('rescan'),
get_missing_url="/get_missing_characters",
clear_covers_url="/clear_all_covers",
generate_url_pattern="/character/{slug}/generate"
) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div> </div>
</div> <select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for char in characters %} {% for char in characters %}
@@ -33,7 +51,10 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ char.name }}</h5> <h5 class="card-title text-center">
{% if char.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ char.name }}
{% if char.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h5>
<p class="card-text small text-center text-muted"> <p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %} {% set ns = namespace(parts=[]) %}
{% for section_key in ['identity', 'defaults'] %} {% for section_key in ['identity', 'defaults'] %}
@@ -58,12 +79,14 @@
{{ ns.parts | join(', ') }} {{ ns.parts | join(', ') }}
</p> </p>
</div> </div>
{% if char.data.lora.lora_name %} <div class="card-footer d-flex justify-content-between align-items-center p-1">
{% if char.data.lora and char.data.lora.lora_name %}
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %} {% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<div class="card-footer text-center p-1"> <small class="text-muted text-truncate" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
<small class="text-muted" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small> {% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="characters" data-slug="{{ char.slug }}" data-name="{{ char.name | e }}">🗑</button>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -71,97 +94,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
document.addEventListener('DOMContentLoaded', () => {
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
async function runBatch() {
const response = await fetch('/get_missing_characters');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert("No characters missing cover images.");
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
// Phase 1: Queue all jobs upfront so the page can be navigated away from
const jobs = [];
for (const char of missing) {
try {
const genResp = await fetch(`/character/${char.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ action: 'replace' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item: char, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${char.name}:`, err);
}
}
// Phase 2: Poll all jobs concurrently; update UI as each finishes
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}`);
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 ${item.name}:`, err);
}
}));
batchBtn.disabled = false;
regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} images queued.`);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_characters');
const data = await response.json();
if (data.missing.length === 0) {
alert("No characters missing cover images.");
return;
}
if (!confirm(`Generate cover images for ${data.missing.length} characters?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm("This will unassign ALL current cover images and generate new ones for every character. Existing files will be kept on disk. Proceed?")) return;
const clearResp = await fetch('/clear_all_covers', { method: 'POST' });
if (clearResp.ok) {
// Update UI to show "No Image" for all
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -31,8 +31,13 @@
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a> <a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a> <a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div> <div class="vr mx-1 d-none d-lg-block"></div>
<!-- Search -->
<form action="/search" method="get" class="d-flex" style="max-width:180px;">
<input type="text" name="q" class="form-control form-control-sm bg-dark text-light border-secondary" placeholder="Search..." aria-label="Search">
</form>
<div class="vr mx-1 d-none d-lg-block"></div>
<!-- Queue indicator --> <!-- Queue indicator -->
<button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Generation Queue"> <button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Job Queue">
<span class="queue-icon"></span> <span class="queue-icon"></span>
<span id="queue-count-badge" class="queue-badge d-none">0</span> <span id="queue-count-badge" class="queue-badge d-none">0</span>
</button> </button>
@@ -93,13 +98,13 @@
</div> </div>
</div> </div>
<!-- Generation Queue Modal --> <!-- Job Queue Modal -->
<div class="modal fade" id="queueModal" tabindex="-1"> <div class="modal fade" id="queueModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title"> <h5 class="modal-title">
Generation Queue Job Queue
<span id="queue-modal-count" class="badge bg-secondary ms-2">0</span> <span id="queue-modal-count" class="badge bg-secondary ms-2">0</span>
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
@@ -232,6 +237,33 @@
} }
} }
function regenerateTags(category, slug) {
const btn = document.getElementById('regenerate-tags-btn');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating…';
fetch(`/api/${category}/${slug}/regenerate_tags`, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
.then(({ok, data}) => {
if (ok && data.success) {
location.reload();
} else {
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = origText;
}
})
.catch(err => {
alert('Regeneration failed: ' + err);
btn.disabled = false;
btn.innerHTML = origText;
});
}
function initJsonEditor(saveUrl) { function initJsonEditor(saveUrl) {
const jsonModal = document.getElementById('jsonEditorModal'); const jsonModal = document.getElementById('jsonEditorModal');
if (!jsonModal) return; if (!jsonModal) return;
@@ -533,7 +565,7 @@
})(); })();
</script> </script>
<script> <script>
// ---- Generation Queue UI ---- // ---- Job Queue UI ----
(function() { (function() {
const badge = document.getElementById('queue-count-badge'); const badge = document.getElementById('queue-count-badge');
const modalCount = document.getElementById('queue-modal-count'); const modalCount = document.getElementById('queue-modal-count');
@@ -575,7 +607,7 @@
queueBtn.title = `${pendingJobs.length} job(s) queued`; queueBtn.title = `${pendingJobs.length} job(s) queued`;
} else { } else {
queueBtn.classList.remove('queue-btn-generating'); queueBtn.classList.remove('queue-btn-generating');
queueBtn.title = 'Generation Queue'; queueBtn.title = 'Job Queue';
} }
// Update modal count // Update modal count
@@ -616,6 +648,15 @@
statusDot.className = `queue-status-dot queue-status-${job.status}`; statusDot.className = `queue-status-dot queue-status-${job.status}`;
li.appendChild(statusDot); li.appendChild(statusDot);
// Job type badge
if (job.job_type === 'llm') {
const typeBadge = document.createElement('span');
typeBadge.className = 'badge bg-info';
typeBadge.textContent = 'LLM';
typeBadge.style.fontSize = '0.6rem';
li.appendChild(typeBadge);
}
// Label // Label
const label = document.createElement('span'); const label = document.createElement('span');
label.className = 'flex-grow-1 small'; label.className = 'flex-grow-1 small';

View File

@@ -15,20 +15,38 @@
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Display Name</label> <label for="name" class="form-label">Display Name</label>
<input type="text" class="form-control" id="name" name="name" required> <input type="text" class="form-control" id="name" name="name" value="{{ form_data.get('name', '') }}" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="character_id" class="form-label">Linked Character</label> <label for="character_id" class="form-label">Linked Character</label>
<select class="form-select" id="character_id" name="character_id"> <select class="form-select" id="character_id" name="character_id">
<option value="">— None —</option> <option value="">— None —</option>
{% for char in characters %} {% for char in characters %}
<option value="{{ char.character_id }}">{{ char.name }}</option> <option value="{{ char.character_id }}" {{ 'selected' if form_data.get('character_id') == char.character_id }}>{{ char.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="mb-3"> {% set tags = form_data.get('tags', {}) if form_data.get('tags') is mapping else {} %}
<label for="tags" class="form-label">Tags (comma separated)</label> <div class="row">
<input type="text" class="form-control" id="tags" name="tags"> <div class="col-md-4 mb-3">
<label for="tag_origin_series" class="form-label">Origin Series</label>
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
</div>
<div class="col-md-4 mb-3">
<label for="tag_origin_type" class="form-label">Origin Type</label>
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw">
<label class="form-check-label" for="tag_nsfw">NSFW</label>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -43,18 +61,18 @@
<select class="form-select" id="lora_lora_name" name="lora_lora_name"> <select class="form-select" id="lora_lora_name" name="lora_lora_name">
<option value="">None</option> <option value="">None</option>
{% for lora in loras %} {% for lora in loras %}
<option value="{{ lora }}">{{ lora }}</option> <option value="{{ lora }}" {{ 'selected' if form_data.get('lora_lora_name') == lora }}>{{ lora }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label for="lora_lora_weight" class="form-label">Weight</label> <label for="lora_lora_weight" class="form-label">Weight</label>
<input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight" value="0.8"> <input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight" value="{{ form_data.get('lora_lora_weight', 0.8) }}">
</div> </div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<label for="lora_lora_triggers" class="form-label">Triggers</label> <label for="lora_lora_triggers" class="form-label">Triggers</label>
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers"> <input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ form_data.get('lora_lora_triggers', '') }}">
</div> </div>
</div> </div>
</div> </div>
@@ -65,11 +83,11 @@
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
<label for="positive" class="form-label">Positive</label> <label for="positive" class="form-label">Positive</label>
<textarea class="form-control" id="positive" name="positive" rows="3"></textarea> <textarea class="form-control" id="positive" name="positive" rows="3">{{ form_data.get('positive', '') }}</textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="negative" class="form-label">Negative</label> <label for="negative" class="form-label">Negative</label>
<textarea class="form-control" id="negative" name="negative" rows="2"></textarea> <textarea class="form-control" id="negative" name="negative" rows="2">{{ form_data.get('negative', '') }}</textarea>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -138,33 +138,30 @@
</div> </div>
</div> </div>
{% set tags = look.data.tags if look.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white"><span>Tags</span></div>
<span>Tags</span>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
{% if preferences is not none %}
{% if 'special::tags' in preferences %}checked{% endif %}
{% elif look.default_fields is not none %}
{% if 'special::tags' in look.default_fields %}checked{% endif %}
{% endif %}>
<label class="form-check-label text-white small {% if look.default_fields is not none and 'special::tags' in look.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
</div>
</div>
<div class="card-body"> <div class="card-body">
{% for tag in look.data.tags %} {% if tags.origin_series %}<span class="badge bg-info">{{ tags.origin_series }}</span>{% endif %}
<span class="badge bg-secondary">{{ tag }}</span> {% if tags.origin_type %}<span class="badge bg-primary">{{ tags.origin_type }}</span>{% endif %}
{% else %} {% if look.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
<span class="text-muted">No tags</span> {% if look.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
{% endfor %}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="mb-0">{{ look.name }}</h1> <h1 class="mb-0">
{{ look.name }}
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/look/{{ look.slug }}/favourite" title="Toggle favourite">
<span style="font-size:1.2rem;">{% if look.is_favourite %}&#9733;{% else %}&#9734;{% endif %}</span>
</button>
{% if look.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h1>
{% if linked_character_ids %} {% if linked_character_ids %}
<small class="text-muted"> <small class="text-muted">
Linked to: Linked to:
@@ -178,6 +175,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('looks', '{{ look.slug }}')">Regenerate Tags</button>
<button type="button" class="btn btn-accent" data-bs-toggle="modal" data-bs-target="#generateCharModal"> <button type="button" class="btn btn-accent" data-bs-toggle="modal" data-bs-target="#generateCharModal">
<i class="bi bi-person-plus"></i> Generate Character <i class="bi bi-person-plus"></i> Generate Character
</button> </button>
@@ -281,6 +279,16 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form'); const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');

View File

@@ -39,9 +39,27 @@
</div> </div>
<div class="form-text">Associates this look with multiple characters for generation and LoRA suggestions.</div> <div class="form-text">Associates this look with multiple characters for generation and LoRA suggestions.</div>
</div> </div>
<div class="mb-3"> {% set tags = look.data.tags if look.data.tags is mapping else {} %}
<label for="tags" class="form-label">Tags (comma separated)</label> <div class="row">
<input type="text" class="form-control" id="tags" name="tags" value="{{ look.data.tags | join(', ') }}"> <div class="col-md-4 mb-3">
<label for="tag_origin_series" class="form-label">Origin Series</label>
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
</div>
<div class="col-md-4 mb-3">
<label for="tag_origin_type" class="form-label">Origin Type</label>
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if look.is_nsfw %}checked{% endif %}>
<label class="form-check-label" for="tag_nsfw">NSFW</label>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> {% from "partials/library_toolbar.html" import library_toolbar %}
<h2>Looks Library</h2> {{ library_toolbar(
<div class="d-flex gap-1 align-items-center"> title="Look",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> category="looks",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_url=url_for('create_look'),
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents"> create_label="Look",
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new look entries from all LoRA files in the Looks folder"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_batch_gen=true,
</form> has_regen_all=true,
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents"> has_lora_create=true,
<input type="hidden" name="overwrite" value="true"> bulk_create_url=url_for('bulk_create_looks_from_loras'),
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all look metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL look LoRAs, consuming significant API credits and overwriting ALL existing look metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_tags=true,
</form> regen_tags_category="looks",
<a href="{{ url_for('create_look') }}" class="btn btn-sm btn-success">Create New Look</a> rescan_url=url_for('rescan_looks'),
<form action="{{ url_for('rescan_looks') }}" method="post" class="d-contents"> get_missing_url="/get_missing_looks",
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan look files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button> clear_covers_url="/clear_all_look_covers",
</form> generate_url_pattern="/look/{slug}/generate"
) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div> </div>
</div> <select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for look in looks %} {% for look in looks %}
@@ -43,7 +55,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ look.name }}</h5> <h5 class="card-title text-center">{% if look.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ look.name }}{% if look.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
{% if look.character_id %} {% if look.character_id %}
<p class="card-text small text-center text-info">{{ look.character_id.replace('_', ' ').title() }}</p> <p class="card-text small text-center text-info">{{ look.character_id.replace('_', ' ').title() }}</p>
{% endif %} {% endif %}
@@ -86,133 +98,11 @@
} }
</style> </style>
<script> <script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight'); const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) { if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`); const card = document.getElementById(`card-${highlightSlug}`);
if (card) { if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const itemNameText = document.getElementById('current-item-name');
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) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
async function runBatch() {
const response = await fetch('/get_missing_looks');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert("No looks missing cover images.");
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
// Phase 1: Queue all jobs upfront
const jobs = [];
for (const item of missing) {
try {
const genResp = await fetch(`/look/${item.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ action: 'replace' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
// Phase 2: Poll all concurrently
// 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}`);
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 ${item.name}:`, err);
}
}));
// Stop polling queue
if (queuePollInterval) {
clearInterval(queuePollInterval);
queuePollInterval = null;
}
batchBtn.disabled = false;
regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} look images processed.`);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_looks');
const data = await response.json();
if (data.missing.length === 0) { alert("No looks missing cover images."); return; }
if (!confirm(`Generate cover images for ${data.missing.length} looks?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm("This will unassign ALL current look cover images and generate new ones. Proceed?")) return;
const clearResp = await fetch('/clear_all_look_covers', { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script> </script>
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -10,23 +10,23 @@
<form action="{{ url_for('create_outfit') }}" method="post"> <form action="{{ url_for('create_outfit') }}" method="post">
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Outfit Name</label> <label for="name" class="form-label">Outfit Name</label>
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. French Maid" required> <input type="text" class="form-control" id="name" name="name" placeholder="e.g. French Maid" value="{{ form_data.get('name', '') }}" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label> <label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. french_maid_01"> <input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. french_maid_01" value="{{ form_data.get('filename', '') }}">
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div> <div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
</div> </div>
<div class="mb-3 form-check form-switch"> <div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked> <input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label> <label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
</div> </div>
<div class="mb-3" id="prompt-group"> <div class="mb-3" id="prompt-group">
<label for="prompt" class="form-label">Description / Concept</label> <label for="prompt" class="form-label">Description / Concept</label>
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the outfit's style, components, colors, and any special features. The AI will generate the full outfit profile based on this."></textarea> <textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the outfit's style, components, colors, and any special features. The AI will generate the full outfit profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
<div class="form-text">Required when AI generation is enabled.</div> <div class="form-text">Required when AI generation is enabled.</div>
</div> </div>

View File

@@ -100,33 +100,29 @@
</div> </div>
</div> </div>
{% set tags = outfit.data.tags if outfit.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white"><span>Tags</span></div>
<span>Tags</span>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
{% if preferences is not none %}
{% if 'special::tags' in preferences %}checked{% endif %}
{% elif outfit.default_fields is not none %}
{% if 'special::tags' in outfit.default_fields %}checked{% endif %}
{% endif %}>
<label class="form-check-label text-white small {% if outfit.default_fields is not none and 'special::tags' in outfit.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
</div>
</div>
<div class="card-body"> <div class="card-body">
{% for tag in outfit.data.tags %} {% if tags.outfit_type %}<span class="badge bg-info">{{ tags.outfit_type }}</span>{% endif %}
<span class="badge bg-secondary">{{ tag }}</span> {% if outfit.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% else %} {% if outfit.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
<span class="text-muted">No tags</span>
{% endfor %}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">{{ outfit.name }}</h1> <h1 class="mb-0">
{{ outfit.name }}
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/outfit/{{ outfit.slug }}/favourite" title="Toggle favourite">
<span style="font-size:1.2rem;">{% if outfit.is_favourite %}&#9733;{% else %}&#9734;{% endif %}</span>
</button>
{% if outfit.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h1>
<a href="{{ url_for('edit_outfit', slug=outfit.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a> <a href="{{ url_for('edit_outfit', slug=outfit.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
<form action="{{ url_for('clone_outfit', slug=outfit.slug) }}" method="post" style="display: inline;"> <form action="{{ url_for('clone_outfit', slug=outfit.slug) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Outfit</button> <button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Outfit</button>
@@ -134,6 +130,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('outfits', '{{ outfit.slug }}')">Regenerate Tags</button>
<a href="{{ url_for('transfer_resource', category='outfits', slug=outfit.slug) }}" class="btn btn-outline-primary">Transfer</a> <a href="{{ url_for('transfer_resource', category='outfits', slug=outfit.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a> <a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div> </div>
@@ -280,6 +277,16 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form'); const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');

View File

@@ -22,9 +22,23 @@
<label for="outfit_id" class="form-label">Outfit ID</label> <label for="outfit_id" class="form-label">Outfit ID</label>
<input type="text" class="form-control" id="outfit_id" name="outfit_id" value="{{ outfit.outfit_id }}"> <input type="text" class="form-control" id="outfit_id" name="outfit_id" value="{{ outfit.outfit_id }}">
</div> </div>
<div class="mb-3"> {% set tags = outfit.data.tags if outfit.data.tags is mapping else {} %}
<label for="tags" class="form-label">Tags (comma separated)</label> <div class="row">
<input type="text" class="form-control" id="tags" name="tags" value="{{ outfit.data.tags | join(', ') }}"> <div class="col-md-6 mb-3">
<label for="tag_outfit_type" class="form-label">Outfit Type</label>
<select class="form-select" id="tag_outfit_type" name="tag_outfit_type">
{% for opt in ['', 'Formal', 'Casual', 'Swimsuit', 'Lingerie', 'Underwear', 'Nude', 'Cosplay', 'Uniform', 'Fantasy', 'Armor', 'Traditional'] %}
<option value="{{ opt }}" {% if tags.outfit_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if outfit.is_nsfw %}checked{% endif %}>
<label class="form-check-label" for="tag_nsfw">NSFW</label>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> {% from "partials/library_toolbar.html" import library_toolbar %}
<h2>Outfit Library</h2> {{ library_toolbar(
<div class="d-flex gap-1 align-items-center"> title="Outfit",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> category="outfits",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_url=url_for('create_outfit'),
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents"> create_label="Outfit",
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new outfit entries from all LoRA files in the Clothing folder"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_batch_gen=true,
</form> has_regen_all=true,
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents"> has_lora_create=true,
<input type="hidden" name="overwrite" value="true"> bulk_create_url=url_for('bulk_create_outfits_from_loras'),
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all outfit metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL outfit LoRAs, consuming significant API credits and overwriting ALL existing outfit metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_tags=true,
</form> regen_tags_category="outfits",
<a href="{{ url_for('create_outfit') }}" class="btn btn-sm btn-success">Create New Outfit</a> rescan_url=url_for('rescan_outfits'),
<form action="{{ url_for('rescan_outfits') }}" method="post" class="d-contents"> get_missing_url="/get_missing_outfits",
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan outfit files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button> clear_covers_url="/clear_all_outfit_covers",
</form> generate_url_pattern="/outfit/{slug}/generate"
) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div> </div>
</div> <select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for outfit in outfits %} {% for outfit in outfits %}
@@ -43,7 +55,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ outfit.name }}</h5> <h5 class="card-title text-center">{% if outfit.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ outfit.name }}{% if outfit.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
<p class="card-text small text-center text-muted"> <p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %} {% set ns = namespace(parts=[]) %}
{% if outfit.data.wardrobe is mapping %} {% if outfit.data.wardrobe is mapping %}
@@ -83,111 +95,12 @@
} }
</style> </style>
<script> <script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter // Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight'); const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) { if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`); const card = document.getElementById(`card-${highlightSlug}`);
if (card) { if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const itemNameText = document.getElementById('current-item-name');
const stepProgressText = document.getElementById('current-step-progress');
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
async function runBatch() {
const response = await fetch('/get_missing_outfits');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert("No outfits missing cover images.");
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
// Phase 1: Queue all jobs upfront
const jobs = [];
for (const item of missing) {
try {
const genResp = await fetch(`/outfit/${item.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
// Phase 2: Poll all concurrently
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
itemNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none');
}
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
}));
batchBtn.disabled = false;
regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} outfit images processed.`);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_outfits');
const data = await response.json();
if (data.missing.length === 0) {
alert("No outfits missing cover images.");
return;
}
if (!confirm(`Generate cover images for ${data.missing.length} outfits?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm("This will unassign ALL current outfit cover images and generate new ones. Proceed?")) return;
const clearResp = await fetch('/clear_all_outfit_covers', { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script> </script>
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,70 @@
{% macro library_toolbar(title, category,
create_url=none, create_label=none,
has_batch_gen=true, has_regen_all=true,
has_lora_create=false, bulk_create_url=none,
has_tags=true, regen_tags_category=none,
rescan_url=none,
get_missing_url=none, clear_covers_url=none,
generate_url_pattern=none) %}
<div class="d-flex justify-content-between align-items-center mb-4"
data-toolbar-category="{{ category }}"
{% if get_missing_url %}data-get-missing-url="{{ get_missing_url }}"{% endif %}
{% if clear_covers_url %}data-clear-covers-url="{{ clear_covers_url }}"{% endif %}
{% if generate_url_pattern %}data-generate-url="{{ generate_url_pattern }}"{% endif %}
{% if regen_tags_category %}data-regen-tags-category="{{ regen_tags_category }}"{% endif %}
{% if bulk_create_url %}data-bulk-create-url="{{ bulk_create_url }}"{% endif %}>
<h2>{{ title }} Library</h2>
<div class="d-flex gap-2 align-items-center">
{% if create_url %}
<a href="{{ create_url }}" class="btn btn-sm btn-success">+ {{ create_label or title }}</a>
{% endif %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if has_batch_gen %}
<li><button class="dropdown-item" id="batch-generate-btn" data-requires="comfyui">
Generate Missing Covers
</button></li>
{% endif %}
{% if has_regen_all %}
<li><button class="dropdown-item text-danger" id="regenerate-all-btn" data-requires="comfyui">
Regenerate All Covers
</button></li>
{% endif %}
{% if (has_batch_gen or has_regen_all) and (has_tags or has_lora_create) %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if has_tags %}
<li><button class="dropdown-item" id="regen-tags-all-btn" data-requires="llm">
Regenerate Tags (LLM)
</button></li>
{% endif %}
{% if has_lora_create %}
<li><button class="dropdown-item" id="bulk-create-btn" data-requires="llm">
Create from LoRAs (LLM)
</button></li>
<li><button class="dropdown-item text-danger" id="bulk-overwrite-btn" data-requires="llm">
Overwrite All from LoRAs (LLM)
</button></li>
{% endif %}
{% if rescan_url %}
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ rescan_url }}" method="post" class="d-contents">
<button type="submit" class="dropdown-item">Rescan from Disk</button>
</form>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -22,12 +22,12 @@
<form action="{{ url_for('create_preset') }}" method="post"> <form action="{{ url_for('create_preset') }}" method="post">
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Preset Name <span class="text-danger">*</span></label> <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"> <input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual" value="{{ form_data.get('name', '') }}">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked> <input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
<label class="form-check-label" for="use_llm">Use AI to build preset from description</label> <label class="form-check-label" for="use_llm">Use AI to build preset from description</label>
</div> </div>
</div> </div>
@@ -37,7 +37,7 @@
<textarea class="form-control" id="description" name="description" rows="5" <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. 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> 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.">{{ form_data.get('description', '') }}</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 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>

View File

@@ -153,6 +153,20 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<hr class="my-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<small class="fw-semibold">Suppress Wardrobe</small>
<div class="form-text mt-0">Strip all clothing/wardrobe prompts from generation</div>
</div>
{% set sw = act.get('suppress_wardrobe') %}
<select class="form-select form-select-sm" name="act_suppress_wardrobe" style="width:auto">
<option value="default" {% if sw is none %}selected{% endif %}>Action default</option>
<option value="true" {% if sw == true %}selected{% endif %}>Always</option>
<option value="false" {% if sw == false %}selected{% endif %}>Never</option>
<option value="random" {% if sw == 'random' %}selected{% endif %}>Random</option>
</select>
</div>
</div> </div>
</div> </div>

View File

@@ -10,13 +10,13 @@
<form action="{{ url_for('create_scene') }}" method="post"> <form action="{{ url_for('create_scene') }}" method="post">
<div class="mb-4"> <div class="mb-4">
<label for="name" class="form-label fw-bold">Scene Name</label> <label for="name" class="form-label fw-bold">Scene Name</label>
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Luxury Bedroom" required> <input type="text" class="form-control" id="name" name="name" placeholder="e.g. Luxury Bedroom" value="{{ form_data.get('name', '') }}" required>
<div class="form-text">The display name for the scene gallery.</div> <div class="form-text">The display name for the scene gallery.</div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="filename" class="form-label fw-bold">Scene ID / Filename <small class="text-muted">(Optional)</small></label> <label for="filename" class="form-label fw-bold">Scene ID / Filename <small class="text-muted">(Optional)</small></label>
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. luxury_bedroom"> <input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. luxury_bedroom" value="{{ form_data.get('filename', '') }}">
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div> <div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
</div> </div>

View File

@@ -116,7 +116,13 @@
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">{{ scene.name }}</h1> <h1 class="mb-0">
{{ scene.name }}
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/scene/{{ scene.slug }}/favourite" title="Toggle favourite">
<span style="font-size:1.2rem;">{% if scene.is_favourite %}&#9733;{% else %}&#9734;{% endif %}</span>
</button>
{% if scene.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h1>
<div class="mt-1"> <div class="mt-1">
<a href="{{ url_for('edit_scene', slug=scene.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Scene</a> <a href="{{ url_for('edit_scene', slug=scene.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Scene</a>
<span class="text-muted">|</span> <span class="text-muted">|</span>
@@ -127,6 +133,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('scenes', '{{ scene.slug }}')">Regenerate Tags</button>
<a href="{{ url_for('transfer_resource', category='scenes', slug=scene.slug) }}" class="btn btn-outline-primary">Transfer</a> <a href="{{ url_for('transfer_resource', category='scenes', slug=scene.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a> <a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div> </div>
@@ -266,6 +273,16 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form'); const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');

View File

@@ -106,11 +106,23 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-light"><strong>Tags</strong></div> <div class="card-header bg-light"><strong>Tags</strong></div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> {% set tags = scene.data.tags if scene.data.tags is mapping else {} %}
<label for="tags" class="form-label">Tags</label> <div class="row">
<input type="text" class="form-control" id="tags" name="tags" <div class="col-md-6 mb-3">
value="{{ scene.data.tags | join(', ') if scene.data.tags else '' }}"> <label for="tag_scene_type" class="form-label">Scene Type</label>
<div class="form-text">Comma-separated tags appended to every generation.</div> <select class="form-select" id="tag_scene_type" name="tag_scene_type">
{% for opt in ['', 'Indoor', 'Outdoor', 'Fantasy', 'Urban', 'Nature', 'Abstract'] %}
<option value="{{ opt }}" {% if tags.scene_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if scene.is_nsfw %}checked{% endif %}>
<label class="form-check-label" for="tag_nsfw">NSFW</label>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> {{ library_toolbar(
<h2>Scene Library</h2> title="Scene",
<div class="d-flex gap-1 align-items-center"> category="scenes",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_url=url_for('create_scene'),
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_label="Scene",
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents"> has_batch_gen=true,
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new scene entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_regen_all=true,
</form> has_lora_create=true,
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents"> bulk_create_url=url_for('bulk_create_scenes_from_loras'),
<input type="hidden" name="overwrite" value="true"> has_tags=true,
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all scene metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL scene LoRAs, consuming significant API credits and overwriting ALL existing scene metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> regen_tags_category="scenes",
</form> rescan_url=url_for('rescan_scenes'),
<a href="{{ url_for('create_scene') }}" class="btn btn-sm btn-success">Create New Scene</a> get_missing_url="/get_missing_scenes",
<form action="{{ url_for('rescan_scenes') }}" method="post" class="d-contents"> clear_covers_url="/clear_all_scene_covers",
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan scene files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button> generate_url_pattern="/scene/{slug}/generate"
</form> ) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div> </div>
</div> <select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for scene in scenes %} {% for scene in scenes %}
@@ -40,7 +52,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ scene.name }}</h5> <h5 class="card-title text-center">{% if scene.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ scene.name }}{% if scene.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
<p class="card-text small text-center text-muted"> <p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %} {% set ns = namespace(parts=[]) %}
{% if scene.data.scene is mapping %} {% if scene.data.scene is mapping %}
@@ -80,110 +92,11 @@
} }
</style> </style>
<script> <script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight'); const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) { if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`); const card = document.getElementById(`card-${highlightSlug}`);
if (card) { if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const sceneNameText = document.getElementById('current-scene-name');
const stepProgressText = document.getElementById('current-step-progress');
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
async function runBatch() {
const response = await fetch('/get_missing_scenes');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert("No scenes missing cover images.");
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
// Phase 1: Queue all jobs upfront
const jobs = [];
for (const scene of missing) {
try {
const genResp = await fetch(`/scene/${scene.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ action: 'replace', character_slug: '' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item: scene, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${scene.name}:`, err);
}
}
// Phase 2: Poll all concurrently
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
itemNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none');
}
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
}));
batchBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} scene images processed.`);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_scenes');
const data = await response.json();
if (data.missing.length === 0) {
alert("No scenes missing cover images.");
return;
}
if (!confirm(`Generate cover images for ${data.missing.length} scenes?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm("This will unassign ALL current scene cover images and generate new ones. Proceed?")) return;
const clearResp = await fetch('/clear_all_scene_covers', { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script> </script>
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %} {% endblock %}

126
templates/search.html Normal file
View File

@@ -0,0 +1,126 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Search</h2>
</div>
<!-- Search form -->
<form method="get" class="mb-4">
<div class="row g-2 align-items-end">
<div class="col-md-5">
<input type="text" name="q" class="form-control" placeholder="Search resources and images..." value="{{ query }}" autofocus>
</div>
<div class="col-auto">
<select name="category" class="form-select form-select-sm" style="width:auto;">
<option value="all" {% if category == 'all' %}selected{% endif %}>All categories</option>
<option value="characters" {% if category == 'characters' %}selected{% endif %}>Characters</option>
<option value="looks" {% if category == 'looks' %}selected{% endif %}>Looks</option>
<option value="outfits" {% if category == 'outfits' %}selected{% endif %}>Outfits</option>
<option value="actions" {% if category == 'actions' %}selected{% endif %}>Actions</option>
<option value="styles" {% if category == 'styles' %}selected{% endif %}>Styles</option>
<option value="scenes" {% if category == 'scenes' %}selected{% endif %}>Scenes</option>
<option value="detailers" {% if category == 'detailers' %}selected{% endif %}>Detailers</option>
<option value="checkpoints" {% if category == 'checkpoints' %}selected{% endif %}>Checkpoints</option>
</select>
</div>
<div class="col-auto">
<select name="nsfw" class="form-select form-select-sm" style="width:auto;">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</div>
<div class="col-auto">
<select name="type" class="form-select form-select-sm" style="width:auto;">
<option value="all" {% if search_type == 'all' %}selected{% endif %}>Resources & Images</option>
<option value="resources" {% if search_type == 'resources' %}selected{% endif %}>Resources only</option>
<option value="images" {% if search_type == 'images' %}selected{% endif %}>Images only</option>
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Search</button>
</div>
</div>
</form>
{% if query %}
<p class="text-muted mb-3">Found {{ total_resources }} resource{{ 's' if total_resources != 1 }} and {{ total_images }} image{{ 's' if total_images != 1 }} for "<strong>{{ query }}</strong>"</p>
{% set type_labels = {
'characters': 'Characters', 'looks': 'Looks', 'outfits': 'Outfits',
'actions': 'Actions', 'styles': 'Styles', 'scenes': 'Scenes',
'detailers': 'Detailers', 'checkpoints': 'Checkpoints'
} %}
{% set type_url_prefix = {
'characters': '/character', 'looks': '/look', 'outfits': '/outfit',
'actions': '/action', 'styles': '/style', 'scenes': '/scene',
'detailers': '/detailer', 'checkpoints': '/checkpoint'
} %}
<!-- Resource results -->
{% if grouped_resources %}
{% for cat_name, items in grouped_resources.items() %}
<div class="mb-4">
<h5>{{ type_labels.get(cat_name, cat_name | capitalize) }} <span class="badge bg-secondary">{{ items | length }}</span></h5>
<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 item in items %}
<div class="col">
<div class="card h-100 character-card" onclick="window.location.href='{{ type_url_prefix.get(cat_name, '/' + cat_name[:-1]) }}/{{ item.slug }}'">
<div class="img-container">
{% if item.image_path %}
<img src="{{ url_for('static', filename='uploads/' + item.image_path) }}" alt="{{ item.name }}">
{% else %}
<span class="text-muted">No Image</span>
{% endif %}
</div>
<div class="card-body">
<h6 class="card-title text-center mb-1">
{% if item.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}
{{ item.name }}
{% if item.is_nsfw %}<span class="badge bg-danger" style="font-size:0.55rem;vertical-align:middle;">NSFW</span>{% endif %}
</h6>
<p class="card-text small text-center text-muted text-truncate" title="{{ item.match_context }}">{{ item.match_context }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
<!-- Image results -->
{% if images %}
<div class="mb-4">
<h5>Gallery Images <span class="badge bg-secondary">{{ images | length }}</span></h5>
<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 img in images %}
<div class="col">
<div class="card h-100 character-card" onclick="window.location.href='/gallery?category={{ img.category }}&slug={{ img.slug }}'">
<div class="img-container">
<img src="{{ url_for('static', filename='uploads/' + img.path) }}" alt="{{ img.slug }}">
{% if img.is_favourite %}
<span class="gallery-fav-star active" style="position:absolute;top:4px;right:4px;font-size:1.2rem;color:#ffc107;">&#9733;</span>
{% endif %}
{% if img.is_nsfw %}
<span class="badge bg-danger" style="position:absolute;top:4px;left:4px;font-size:0.55rem;">NSFW</span>
{% endif %}
</div>
<div class="card-body py-1">
<p class="card-text small text-center text-muted text-truncate">{{ img.category }}/{{ img.slug }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if not grouped_resources and not images %}
<p class="text-muted">No results found.</p>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -164,6 +164,32 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Tag Management -->
<div class="card mb-4">
<div class="card-header bg-dark text-white">Tag Management</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Migrate Tags</h6>
<p class="text-muted small">Convert old list-format tags to new structured dict format across all resources.</p>
<button class="btn btn-warning" id="migrate-tags-btn" onclick="migrateTags()">Migrate Tags to New Format</button>
<span id="migrate-tags-status" class="ms-2"></span>
</div>
<div class="col-md-6">
<h6>Bulk Regenerate Tags</h6>
<p class="text-muted small">Use LLM to regenerate structured tags for all resources. This will overwrite existing tags.</p>
<button class="btn btn-danger" id="bulk-regen-btn" onclick="bulkRegenerateTags()">Regenerate All Tags (LLM)</button>
<div id="bulk-regen-progress" class="mt-2" style="display: none;">
<div class="progress">
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
</div>
<small class="text-muted" id="bulk-regen-status"></small>
</div>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -339,5 +365,59 @@
} }
}); });
}); });
async function migrateTags() {
const btn = document.getElementById('migrate-tags-btn');
const status = document.getElementById('migrate-tags-status');
if (!confirm('Convert all old list-format tags to new dict format?')) return;
btn.disabled = true;
status.textContent = 'Migrating...';
try {
const resp = await fetch('/admin/migrate_tags', { method: 'POST' });
const data = await resp.json();
status.textContent = data.success ? `Done! Migrated ${data.migrated} resources.` : `Error: ${data.error}`;
} catch (err) {
status.textContent = 'Failed: ' + err.message;
} finally {
btn.disabled = false;
}
}
async function bulkRegenerateTags() {
if (!confirm('Regenerate tags for ALL resources using the LLM? This may take a while and will overwrite existing tags.')) return;
const btn = document.getElementById('bulk-regen-btn');
const progress = document.getElementById('bulk-regen-progress');
const bar = progress.querySelector('.progress-bar');
const status = document.getElementById('bulk-regen-status');
btn.disabled = true;
progress.style.display = 'block';
const categories = ['characters', 'outfits', 'actions', 'styles', 'scenes', 'detailers', 'looks'];
// Fetch all slugs per category
let allItems = [];
for (const cat of categories) {
try {
const resp = await fetch(`/get_missing_${cat}`);
// This endpoint returns items missing covers, but we need ALL items.
// Instead, we'll use a simpler approach: fetch the index page data
} catch (e) {}
}
// Use a simpler approach: call regenerate for each category via a bulk endpoint
status.textContent = 'Queuing regeneration for all resources...';
try {
const resp = await fetch('/admin/bulk_regenerate_tags', { method: 'POST' });
const data = await resp.json();
if (data.success) {
bar.style.width = '100%';
status.textContent = `Queued ${data.total} resources for regeneration. Check console for progress.`;
} else {
status.textContent = `Error: ${data.error}`;
}
} catch (err) {
status.textContent = 'Failed: ' + err.message;
} finally {
btn.disabled = false;
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -10,13 +10,13 @@
<form action="{{ url_for('create_style') }}" method="post"> <form action="{{ url_for('create_style') }}" method="post">
<div class="mb-4"> <div class="mb-4">
<label for="name" class="form-label fw-bold">Style Name</label> <label for="name" class="form-label fw-bold">Style Name</label>
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Sabu Style" required> <input type="text" class="form-control" id="name" name="name" placeholder="e.g. Sabu Style" value="{{ form_data.get('name', '') }}" required>
<div class="form-text">The display name for the style gallery.</div> <div class="form-text">The display name for the style gallery.</div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="filename" class="form-label fw-bold">Style ID / Filename <small class="text-muted">(Optional)</small></label> <label for="filename" class="form-label fw-bold">Style ID / Filename <small class="text-muted">(Optional)</small></label>
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. sabu_01"> <input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. sabu_01" value="{{ form_data.get('filename', '') }}">
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div> <div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
</div> </div>

View File

@@ -116,7 +116,13 @@
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">{{ style.name }}</h1> <h1 class="mb-0">
{{ style.name }}
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/style/{{ style.slug }}/favourite" title="Toggle favourite">
<span style="font-size:1.2rem;">{% if style.is_favourite %}&#9733;{% else %}&#9734;{% endif %}</span>
</button>
{% if style.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h1>
<div class="mt-1"> <div class="mt-1">
<a href="{{ url_for('edit_style', slug=style.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Style</a> <a href="{{ url_for('edit_style', slug=style.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Style</a>
<span class="text-muted">|</span> <span class="text-muted">|</span>
@@ -127,6 +133,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button> <button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('styles', '{{ style.slug }}')">Regenerate Tags</button>
<a href="{{ url_for('transfer_resource', category='styles', slug=style.slug) }}" class="btn btn-outline-primary">Transfer</a> <a href="{{ url_for('transfer_resource', category='styles', slug=style.slug) }}" class="btn btn-outline-primary">Transfer</a>
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a> <a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div> </div>
@@ -258,6 +265,16 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form'); const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');

View File

@@ -81,6 +81,31 @@
</div> </div>
</div> </div>
<!-- Tags -->
<div class="card mb-4">
<div class="card-header bg-light"><strong>Tags</strong></div>
<div class="card-body">
{% set tags = style.data.tags if style.data.tags is mapping else {} %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="tag_style_type" class="form-label">Style Type</label>
<select class="form-select" id="tag_style_type" name="tag_style_type">
{% for opt in ['', 'Anime', 'Realistic', 'Western', 'Artistic', 'Sketch', 'Watercolor', 'Digital', 'Pixel Art'] %}
<option value="{{ opt }}" {% if tags.style_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if style.is_nsfw %}checked{% endif %}>
<label class="form-check-label" for="tag_nsfw">NSFW</label>
</div>
</div>
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{{ url_for('style_detail', slug=style.slug) }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('style_detail', slug=style.slug) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> {{ library_toolbar(
<h2>Style Library</h2> title="Style",
<div class="d-flex gap-1 align-items-center"> category="styles",
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_url=url_for('create_style'),
<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" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button> create_label="Style",
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents"> has_batch_gen=true,
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new style entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> has_regen_all=true,
</form> has_lora_create=true,
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents"> bulk_create_url=url_for('bulk_create_styles_from_loras'),
<input type="hidden" name="overwrite" value="true"> has_tags=true,
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all style metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL style LoRAs, consuming significant API credits and overwriting ALL existing style metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button> regen_tags_category="styles",
</form> rescan_url=url_for('rescan_styles'),
<a href="{{ url_for('create_style') }}" class="btn btn-sm btn-success">Create New Style</a> get_missing_url="/get_missing_styles",
<form action="{{ url_for('rescan_styles') }}" method="post" class="d-contents"> clear_covers_url="/clear_all_style_covers",
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan style files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button> generate_url_pattern="/style/{slug}/generate"
</form> ) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div> </div>
</div> <select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for style in styles %} {% for style in styles %}
@@ -40,7 +52,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5 class="card-title text-center">{{ style.name }}</h5> <h5 class="card-title text-center">{% if style.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ style.name }}{% if style.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
<p class="card-text small text-center text-muted"> <p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %} {% set ns = namespace(parts=[]) %}
{% if style.data.style is mapping %} {% if style.data.style is mapping %}
@@ -80,111 +92,11 @@
} }
</style> </style>
<script> <script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight'); const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) { if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`); const card = document.getElementById(`card-${highlightSlug}`);
if (card) { if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const styleNameText = document.getElementById('current-style-name');
const stepProgressText = document.getElementById('current-step-progress');
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
else nodeStatus.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
async function runBatch() {
const response = await fetch('/get_missing_styles');
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert("No styles missing cover images.");
return;
}
batchBtn.disabled = true;
regenAllBtn.disabled = true;
// Phase 1: Queue all jobs upfront
const jobs = [];
for (const style of missing) {
try {
const genResp = await fetch(`/style/${style.slug}/generate`, {
method: 'POST',
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item: style, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${style.name}:`, err);
}
}
// Phase 2: Poll all concurrently
let currentItem = '';
await Promise.all(jobs.map(async ({ item, jobId }) => {
currentItem = item.name;
styleNameText.textContent = `Processing: ${currentItem}`;
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none');
}
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
}));
batchBtn.disabled = false;
regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} style images processed.`);
}
batchBtn.addEventListener('click', async () => {
const response = await fetch('/get_missing_styles');
const data = await response.json();
if (data.missing.length === 0) {
alert("No styles missing cover images.");
return;
}
if (!confirm(`Generate cover images for ${data.missing.length} styles?`)) return;
runBatch();
});
regenAllBtn.addEventListener('click', async () => {
if (!confirm("This will unassign ALL current style cover images and generate new ones. Proceed?")) return;
const clearResp = await fetch('/clear_all_style_covers', { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
});
</script> </script>
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %} {% endblock %}