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
queue_api.py # /api/queue/* endpoints
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
@@ -90,6 +92,8 @@ All category models (except Settings and Checkpoint) share this pattern:
- `data` — full JSON blob (SQLAlchemy JSON column)
- `default_fields` — list of `section::key` strings saved as the user's preferred prompt fields
- `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
@@ -156,10 +160,16 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
### `services/job_queue.py` — Background Job Queue
Two independent queues with separate worker threads:
- **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.
- **`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
@@ -170,13 +180,14 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
### `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/`.
- **`call_mcp_tool()`** — Synchronous wrapper for MCP tool calls.
### `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_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.
### `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/strengths.py`: `_build_strengths_prompts()`, `_prepare_strengths_workflow()` — strengths gallery helpers
- `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": "" },
"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" }
}
```
@@ -234,7 +247,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
"outfit_name": "French Maid",
"wardrobe": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "accessories": "" },
"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": { "full_body": "", "additional": "", "head": "", "eyes": "", "arms": "", "hands": "" },
"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": { "background": "", "foreground": "", "furniture": "", "colors": "", "lighting": "", "theme": "" },
"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_name": "Watercolor",
"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",
"prompt": ["detailed skin", "pores"],
"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",
"negative": "revealing",
"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.
@@ -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)
Each category follows the same URL pattern:
- `GET /<category>/`gallery
- `GET /<category>/`library with favourite/NSFW filter controls
- `GET /<category>/<slug>` — detail + generation UI
- `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}`
- `POST /<category>/<slug>/replace_cover_from_preview`
- `GET/POST /<category>/<slug>/edit`
- `POST /<category>/<slug>/upload`
- `POST /<category>/<slug>/save_defaults`
- `POST /<category>/<slug>/favourite` — toggle `is_favourite` (AJAX)
- `POST /<category>/<slug>/clone` — duplicate entry
- `POST /<category>/<slug>/save_json` — save raw JSON (from modal editor)
- `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.
### 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
- `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)
@@ -415,8 +444,10 @@ Text files in `data/prompts/` define JSON output schemas for LLM-generated entri
- `character_system.txt` — character 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`
- `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
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 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.
- **AJAX detection**: `request.headers.get('X-Requested-With') == 'XMLHttpRequest'` determines whether to return JSON or redirect.
- **Session must be marked modified for JSON responses**: After setting session values in AJAX-responding routes, set `session.modified = True`.
- **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. When merging into `tags` for `build_prompt`, use `extend` for lists and `append` for strings — never append the list object itself or `", ".join()` will fail on the nested list item.
- **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"`.
- **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:
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
if not Settings.query.first():
db.session.add(Settings())

View File

@@ -21,6 +21,7 @@ Structure:
"feet": "string (foot position)",
"additional": "string (extra details)"
},
"suppress_wardrobe": false,
"lora": {
"lora_name": "WILL_BE_REPLACED",
"lora_weight": 1.0,
@@ -28,8 +29,16 @@ Structure:
"lora_weight_max": 1.0,
"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.
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).

View File

@@ -50,6 +50,13 @@ Structure:
"lora_weight_max": 1.0,
"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,8 +16,15 @@ Structure:
"steps": 25,
"cfg": 5.0,
"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:
- "base_positive": Comma-separated tags that improve output quality for this specific model. Look for recommended positive prompt tags in the HTML.

View File

@@ -18,8 +18,16 @@ Structure:
"lora_weight_min": 0.7,
"lora_weight_max": 1.0,
"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.
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).

View File

@@ -23,8 +23,15 @@ Structure:
"lora_weight_max": 1.0,
"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.
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).

View File

@@ -30,6 +30,12 @@ Structure:
"lora_weight_max": 1.0,
"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_id": "specific_id | random | null",
"use_lora": true,
"suppress_wardrobe": null,
"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 },
@@ -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 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.
- `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).
- 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.

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_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.
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).

View File

@@ -21,8 +21,15 @@ Structure:
"lora_weight_min": 0.7,
"lora_weight_max": 1.0,
"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.
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).

View File

@@ -13,6 +13,9 @@ class Character(db.Model):
image_path = db.Column(db.String(255), nullable=True)
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)
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
@@ -161,6 +164,8 @@ class Look(db.Model):
data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, 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):
"""Get all characters linked to this look."""
@@ -192,6 +197,8 @@ class Outfit(db.Model):
data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, 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):
return f'<Outfit {self.outfit_id}>'
@@ -205,6 +212,8 @@ class Action(db.Model):
data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, 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):
return f'<Action {self.action_id}>'
@@ -218,6 +227,8 @@ class Style(db.Model):
data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, 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):
return f'<Style {self.style_id}>'
@@ -231,6 +242,8 @@ class Scene(db.Model):
data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, 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):
return f'<Scene {self.scene_id}>'
@@ -244,6 +257,8 @@ class Detailer(db.Model):
data = db.Column(db.JSON, nullable=False)
default_fields = db.Column(db.JSON, 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):
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"
data = db.Column(db.JSON, 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):
return f'<Checkpoint {self.checkpoint_id}>'

View File

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

View File

@@ -1,7 +1,6 @@
import json
import os
import re
import time
import random
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 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.sync import sync_actions
from services.file_io import get_available_loras
@@ -38,8 +37,17 @@ def register_routes(app):
@app.route('/actions')
def actions_index():
actions = Action.query.order_by(Action.name).all()
return render_template('actions/index.html', actions=actions)
query = Action.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)
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'])
def rescan_actions():
@@ -118,9 +126,15 @@ def register_routes(app):
else:
new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list)
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
# Suppress wardrobe toggle
new_data['suppress_wardrobe'] = request.form.get('suppress_wardrobe') == 'on'
# 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
flag_modified(action, "data")
@@ -201,6 +215,12 @@ def register_routes(app):
session[f'extra_neg_action_{slug}'] = extra_negative
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
if character:
# Combine character identity/wardrobe with action details
@@ -232,16 +252,13 @@ def register_routes(app):
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']}"
# Merge tags
combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', [])))
# Use action's defaults if no manual selection
if not selected_fields:
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
# Auto-include essential character fields if a character is selected
if selected_fields:
_ensure_character_fields(character, selected_fields)
_ensure_character_fields(character, selected_fields, include_wardrobe=not suppress_wardrobe)
else:
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
@@ -249,7 +266,8 @@ def register_routes(app):
for key in ['base', 'head']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
# Add wardrobe fields
# Add wardrobe fields (unless suppressed)
if not suppress_wardrobe:
from utils import _WARDROBE_KEYS
wardrobe = character.get_active_wardrobe()
for key in _WARDROBE_KEYS:
@@ -281,7 +299,7 @@ def register_routes(app):
'tags': action_obj.data.get('tags', [])
}
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
active_outfit = 'default'
@@ -322,7 +340,8 @@ def register_routes(app):
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
extra_parts.append(val)
# Wardrobe (active outfit)
# Wardrobe (active outfit) — skip if suppressed
if not suppress_wardrobe:
from utils import _WARDROBE_KEYS
wardrobe = extra_char.get_active_wardrobe()
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('/')
_lora_subfolder = os.path.basename(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')
return redirect(url_for('actions_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('action_system.txt')
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')
return redirect(url_for('actions_index'))
for filename in os.listdir(actions_lora_dir):
if filename.endswith('.safetensors'):
job_ids = []
skipped = 0
for filename in sorted(os.listdir(actions_lora_dir)):
if not filename.endswith('.safetensors'):
continue
name_base = filename.rsplit('.', 1)[0]
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
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)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
html_path = os.path.join(actions_lora_dir, html_filename)
# Read HTML companion file if it exists
html_path = os.path.join(actions_lora_dir, f"{name_base}.html")
html_content = ""
if os.path.exists(html_path):
try:
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
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'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML {html_filename}: {e}")
except Exception:
pass
try:
print(f"Asking LLM to describe action: {action_name}")
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{filename}'"
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###"
def make_task(fn, aid, aname, jp, lsf, html_ctx, sys_prompt, is_exist):
def task_fn(job):
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{fn}'"
if html_ctx:
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)
# Clean response
llm_response = call_llm(prompt, sys_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
action_data = json.loads(clean_json)
# Enforce system values while preserving LLM-extracted metadata
action_data['action_id'] = action_id
action_data['action_name'] = action_name
action_data['action_id'] = aid
action_data['action_name'] = aname
# Update lora dict safely
if 'lora' not in action_data: action_data['lora'] = {}
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if 'lora' not in action_data:
action_data['lora'] = {}
action_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
# Fallbacks if LLM failed to extract metadata
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:
action_data['lora']['lora_weight'] = 1.0
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:
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)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': aname, 'action': 'overwritten' if is_exist else 'created'}
return task_fn
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
job = _enqueue_task(
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:
print(f"Error creating action for {filename}: {e}")
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_actions()
msg = f'Successfully processed actions: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No actions created or overwritten. {skipped_count} existing actions found.')
job['result'] = {'synced': True}
_enqueue_task("Sync actions DB", sync_task)
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'))
@app.route('/action/create', methods=['GET', 'POST'])
def create_action():
form_data = {}
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
prompt = request.form.get('prompt', '')
use_llm = request.form.get('use_llm') == 'on'
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -513,12 +542,12 @@ def register_routes(app):
if use_llm:
if not prompt:
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')
if not system_prompt:
flash("Action system prompt file not found.")
return redirect(request.url)
return render_template('actions/create.html', form_data=form_data)
try:
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:
print(f"LLM error: {e}")
flash(f"Failed to generate action profile: {e}")
return redirect(request.url)
return render_template('actions/create.html', form_data=form_data)
else:
action_data = {
"action_id": safe_slug,
@@ -538,6 +567,7 @@ def register_routes(app):
"base": "", "head": "", "upper_body": "", "lower_body": "",
"hands": "", "feet": "", "additional": ""
},
"suppress_wardrobe": False,
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
"tags": []
}
@@ -559,9 +589,9 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {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'])
def clone_action(slug):
@@ -619,3 +649,12 @@ def register_routes(app):
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
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 services.file_io import get_available_loras
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.sync import sync_characters
from services.workflow import _get_default_checkpoint, _prepare_workflow
@@ -23,8 +23,17 @@ def register_routes(app):
@app.route('/')
def index():
characters = Character.query.order_by(Character.name).all()
return render_template('index.html', characters=characters)
query = Character.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)
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'])
def rescan():
@@ -219,6 +228,7 @@ def register_routes(app):
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
prompt = request.form.get('prompt', '')
wiki_url = request.form.get('wiki_url', '').strip()
use_llm = request.form.get('use_llm') == 'on'
outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none'
existing_outfit_id = request.form.get('existing_outfit_id')
@@ -228,6 +238,7 @@ def register_routes(app):
'name': name,
'filename': slug,
'prompt': prompt,
'wiki_url': wiki_url,
'use_llm': use_llm,
'outfit_mode': outfit_mode,
'existing_outfit_id': existing_outfit_id
@@ -261,6 +272,20 @@ def register_routes(app):
flash(error_msg)
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
default_outfit_id = 'default'
generated_outfit = None
@@ -271,7 +296,7 @@ def register_routes(app):
outfit_name = f"{name} - default"
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."""
@@ -344,7 +369,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
# Step 2: Generate character (without wardrobe section)
char_prompt = f"""Generate a character named "{name}".
Description: {prompt}
Description: {prompt}{wiki_reference}
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:
new_data['wardrobe'][key] = request.form.get(form_key)
# Update Tags (comma separated string to list)
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
# Update structured tags
new_data['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,
}
character.is_nsfw = new_data['tags']['nsfw']
character.data = new_data
flag_modified(character, "data")
@@ -867,3 +896,12 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
db.session.commit()
flash('Default prompt selection saved for this character!')
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 os
import re
import time
import logging
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 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.sync import sync_checkpoints, _default_checkpoint_data
from services.file_io import get_available_checkpoints
@@ -57,8 +56,17 @@ def register_routes(app):
@app.route('/checkpoints')
def checkpoints_index():
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
return render_template('checkpoints/index.html', checkpoints=checkpoints)
query = Checkpoint.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)
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'])
def rescan_checkpoints():
@@ -189,9 +197,9 @@ def register_routes(app):
os.makedirs(checkpoints_dir, exist_ok=True)
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
skipped = 0
written_directly = 0
job_ids = []
system_prompt = load_prompt('checkpoint_system.txt')
if not system_prompt:
@@ -219,7 +227,7 @@ def register_routes(app):
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
# 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)
html_content = ' '.join(clean_html.split())
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)
if html_content:
try:
print(f"Asking LLM to describe checkpoint: {filename}")
# Has HTML companion — enqueue LLM task
def make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing):
def task_fn(job):
prompt = (
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
f"(checkpoint_path: '{checkpoint_path}').\n\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)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
ckpt_data = json.loads(clean_json)
# Enforce fixed fields
ckpt_data['checkpoint_path'] = checkpoint_path
ckpt_data['checkpoint_name'] = filename
# Fill missing fields with defaults
for key, val in defaults.items():
if key not in ckpt_data or ckpt_data[key] is None:
ckpt_data[key] = val
time.sleep(0.5)
except Exception as e:
print(f"LLM error for {filename}: {e}. Using defaults.")
ckpt_data = defaults
else:
logger.error("LLM error for %s: %s. Using defaults.", filename, e)
ckpt_data = defaults
try:
with open(json_path, 'w') as f:
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:
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:
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()
msg = f'Successfully processed checkpoints: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
job['result'] = {'synced': True}
_enqueue_task("Sync checkpoints DB", sync_task)
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'))
@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 os
import re
import time
import logging
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 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.sync import sync_detailers
from services.file_io import get_available_loras
@@ -27,11 +26,8 @@ def register_routes(app):
combined_data = character.data.copy()
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', '')
if detailer_prompt:
if 'tags' not in combined_data: combined_data['tags'] = []
combined_data['tags'].append(detailer_prompt)
# Merge detailer lora triggers if present
detailer_lora = detailer_obj.data.get('lora', {})
@@ -53,21 +49,19 @@ def register_routes(app):
for key in _WARDROBE_KEYS:
if wardrobe.get(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
active_outfit = character.active_outfit
else:
# Detailer only - no character
detailer_prompt = detailer_obj.data.get('prompt', '')
detailer_tags = [detailer_prompt] if detailer_prompt else []
combined_data = {
'character_id': detailer_obj.detailer_id,
'tags': detailer_tags,
'lora': detailer_obj.data.get('lora', {}),
}
if not selected_fields:
selected_fields = ['special::tags', 'lora::lora_triggers']
selected_fields = ['lora::lora_triggers']
default_fields = detailer_obj.default_fields
active_outfit = 'default'
@@ -76,6 +70,11 @@ def register_routes(app):
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)
if extra_positive:
@@ -87,8 +86,17 @@ def register_routes(app):
@app.route('/detailers')
def detailers_index():
detailers = Detailer.query.order_by(Detailer.name).all()
return render_template('detailers/index.html', detailers=detailers)
query = Detailer.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)
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'])
def rescan_detailers():
@@ -162,9 +170,13 @@ def register_routes(app):
else:
new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list)
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
# Update structured tags
new_data['tags'] = {
'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
flag_modified(detailer, "data")
@@ -318,15 +330,16 @@ def register_routes(app):
return redirect(url_for('detailers_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
skipped = 0
job_ids = []
system_prompt = load_prompt('detailer_system.txt')
if not system_prompt:
flash('Detailer system prompt file not found.', 'error')
return redirect(url_for('detailers_index'))
detailers_dir = app.config['DETAILERS_DIR']
for filename in os.listdir(detailers_lora_dir):
if filename.endswith('.safetensors'):
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()
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)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
@@ -354,10 +367,10 @@ def register_routes(app):
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML {html_filename}: {e}")
logger.error("Error reading HTML %s: %s", html_filename, e)
try:
print(f"Asking LLM to describe detailer: {detailer_name}")
def make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
def task_fn(job):
prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'"
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###"
@@ -384,33 +397,33 @@ def register_routes(app):
with open(json_path, 'w') as f:
json.dump(detailer_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': detailer_name, 'action': 'overwritten' if is_existing else 'created'}
return task_fn
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
except Exception as e:
print(f"Error creating detailer for {filename}: {e}")
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))
job_ids.append(job['id'])
if created_count > 0 or overwritten_count > 0:
if job_ids:
def sync_task(job):
sync_detailers()
msg = f'Successfully processed detailers: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No new detailers created or overwritten. {skipped_count} existing detailers found.')
job['result'] = {'synced': True}
_enqueue_task("Sync detailers DB", sync_task)
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'))
@app.route('/detailer/create', methods=['GET', 'POST'])
def create_detailer():
form_data = {}
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
form_data = {'name': name, 'filename': slug}
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -452,6 +465,15 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {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 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')
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints']
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints', 'looks', 'presets', 'generator']
_MODEL_MAP = {
'characters': Character,
@@ -20,11 +20,36 @@ _MODEL_MAP = {
'styles': Style,
'detailers': Detailer,
'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 _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=''):
"""Return sorted list of image dicts from the uploads directory."""
upload_folder = app.config['UPLOAD_FOLDER']
@@ -164,18 +189,48 @@ def register_routes(app):
category = request.args.get('category', 'all')
slug = request.args.get('slug', '')
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)))
per_page = int(request.args.get('per_page', 48))
per_page = per_page if per_page in (24, 48, 96) else 48
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':
images.reverse()
elif sort == 'random':
import random
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_pages = max(1, (total + per_page - 1) // per_page)
page = min(page, total_pages)
@@ -197,6 +252,11 @@ def register_routes(app):
if Model:
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(
'gallery.html',
images=page_images,
@@ -209,6 +269,10 @@ def register_routes(app):
sort=sort,
categories=GALLERY_CATEGORIES,
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')
@@ -228,8 +292,60 @@ def register_routes(app):
meta = _parse_comfy_png_metadata(abs_img)
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
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'])
def gallery_delete():
"""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):
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'}
@@ -260,6 +380,7 @@ def register_routes(app):
hard: removes JSON data file + LoRA/checkpoint safetensors + DB record.
"""
_RESOURCE_MODEL_MAP = {
'characters': Character,
'looks': Look,
'styles': Style,
'actions': Action,
@@ -269,6 +390,7 @@ def register_routes(app):
'checkpoints': Checkpoint,
}
_RESOURCE_DATA_DIRS = {
'characters': app.config['CHARACTERS_DIR'],
'looks': app.config['LOOKS_DIR'],
'styles': app.config['STYLES_DIR'],
'actions': app.config['ACTIONS_DIR'],

View File

@@ -1,154 +1,135 @@
import json
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 services.prompts import build_prompt, build_extras_prompt
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize
from flask import render_template, request, redirect, url_for, flash
from models import Preset
from services.generation import generate_from_preset
from services.file_io import get_available_checkpoints
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')
def register_routes(app):
@app.route('/generator', methods=['GET', 'POST'])
@app.route('/generator', methods=['GET'])
def generator():
characters = Character.query.order_by(Character.name).all()
presets = Preset.query.order_by(Preset.name).all()
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()
if not selected_ckpt:
default_path, _ = _get_default_checkpoint()
selected_ckpt = default_path
if request.method == 'POST':
char_slug = request.form.get('character')
checkpoint = request.form.get('checkpoint')
custom_positive = request.form.get('positive_prompt', '')
custom_negative = request.form.get('negative_prompt', '')
# Pre-select preset from query param
preset_slug = request.args.get('preset', '')
action_slugs = request.form.getlist('action_slugs')
outfit_slugs = request.form.getlist('outfit_slugs')
scene_slugs = request.form.getlist('scene_slugs')
style_slugs = request.form.getlist('style_slugs')
detailer_slugs = request.form.getlist('detailer_slugs')
override_prompt = request.form.get('override_prompt', '').strip()
width = request.form.get('width') or 1024
height = request.form.get('height') or 1024
return render_template('generator.html',
presets=presets,
checkpoints=checkpoints,
selected_ckpt=selected_ckpt,
preset_slug=preset_slug)
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 []
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
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 []
preset = Preset.query.filter_by(slug=preset_slug).first()
if not preset:
return {'error': 'Preset not found'}, 404
try:
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
overrides = {
'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()
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
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint).first() if checkpoint else None
workflow = _prepare_workflow(
workflow, character, prompts, checkpoint, custom_negative,
outfit=sel_outfits[0] if sel_outfits else None,
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,
)
width = request.form.get('width', '').strip()
height = request.form.get('height', '').strip()
if width and height:
overrides['width'] = int(width)
overrides['height'] = int(height)
print(f"Queueing generator prompt for {character.character_id}")
_finalize = _make_finalize('characters', character.slug)
label = f"Generator: {character.name}"
job = _enqueue_job(label, workflow, _finalize)
job = generate_from_preset(preset, overrides, save_category='generator')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
flash("Generation queued.")
return redirect(url_for('generator', preset=preset_slug))
except Exception as e:
print(f"Generator error: {e}")
logger.exception("Generator error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error: {str(e)}")
return redirect(url_for('generator', preset=preset_slug))
return render_template('generator.html', characters=characters, checkpoints=checkpoints,
actions=actions, outfits=outfits, scenes=scenes,
styles=styles, detailers=detailers, selected_ckpt=selected_ckpt)
@app.route('/generator/preset_info', methods=['GET'])
def generator_preset_info():
"""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'])
def generator_preview_prompt():
char_slug = request.form.get('character')
if not char_slug:
return {'error': 'No character selected'}, 400
preset = Preset.query.filter_by(slug=slug).first()
if not preset:
return {'error': 'not found'}, 404
character = Character.query.filter_by(slug=char_slug).first()
if not character:
return {'error': 'Character not found'}, 404
data = preset.data
info = {}
action_slugs = request.form.getlist('action_slugs')
outfit_slugs = request.form.getlist('outfit_slugs')
scene_slugs = request.form.getlist('scene_slugs')
style_slugs = request.form.getlist('style_slugs')
detailer_slugs = request.form.getlist('detailer_slugs')
custom_positive = request.form.get('positive_prompt', '')
# Character
char_cfg = data.get('character', {})
char_id = char_cfg.get('character_id')
if char_id == 'random':
info['character'] = 'Random'
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 []
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
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 []
# Secondary entities
for key, label in [('outfit', 'outfit'), ('action', 'action'), ('style', 'style'),
('scene', 'scene'), ('detailer', 'detailer'), ('look', 'look')]:
cfg = data.get(key, {})
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)
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}"
# Checkpoint
ckpt_cfg = data.get('checkpoint', {})
ckpt_path = ckpt_cfg.get('checkpoint_path')
if ckpt_path == 'random':
info['checkpoint'] = 'Random'
elif ckpt_path:
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 os
import re
import time
import logging
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 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.sync import sync_looks
from services.file_io import get_available_loras, _count_look_assignments
@@ -58,9 +57,18 @@ def register_routes(app):
@app.route('/looks')
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()
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'])
def rescan_looks():
@@ -144,8 +152,12 @@ def register_routes(app):
except ValueError:
pass
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
new_data['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,
}
look.is_nsfw = new_data['tags']['nsfw']
look.data = new_data
flag_modified(look, 'data')
@@ -435,19 +447,32 @@ Character ID: {character_slug}"""
def create_look():
characters = Character.query.order_by(Character.name).all()
loras = get_available_loras('characters')
form_data = {}
if request.method == 'POST':
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
lora_name = request.form.get('lora_lora_name', '')
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
lora_triggers = request.form.get('lora_lora_triggers', '')
positive = request.form.get('positive', '')
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 = {
'look_id': look_id,
@@ -459,20 +484,26 @@ Character ID: {character_slug}"""
'tags': tags
}
try:
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
with open(file_path, 'w') as f:
json.dump(data, f, indent=2)
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
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.commit()
flash(f'Look "{name}" created!')
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')
def get_missing_looks():
@@ -497,15 +528,16 @@ Character ID: {character_slug}"""
return redirect(url_for('looks_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
skipped = 0
job_ids = []
system_prompt = load_prompt('look_system.txt')
if not system_prompt:
flash('Look system prompt file not found.', 'error')
return redirect(url_for('looks_index'))
looks_dir = app.config['LOOKS_DIR']
for filename in os.listdir(lora_dir):
if not filename.endswith('.safetensors'):
continue
@@ -515,11 +547,11 @@ Character ID: {character_slug}"""
look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
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)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
@@ -535,10 +567,10 @@ Character ID: {character_slug}"""
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML {html_filename}: {e}")
logger.error("Error reading HTML %s: %s", html_filename, e)
try:
print(f"Asking LLM to describe look: {look_name}")
def make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing):
def task_fn(job):
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
if html_content:
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:
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:
json.dump(look_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': look_name, 'action': 'overwritten' if is_existing else 'created'}
return task_fn
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:
print(f"Error creating look for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
if job_ids:
def sync_task(job):
sync_looks()
msg = f'Successfully processed looks: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No looks created or overwritten. {skipped_count} existing entries found.')
job['result'] = {'synced': True}
_enqueue_task("Sync looks DB", sync_task)
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'))
@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 os
import re
import time
import logging
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 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.sync import sync_outfits
from services.file_io import get_available_loras, _count_outfit_lora_assignments
@@ -37,9 +36,18 @@ def register_routes(app):
@app.route('/outfits')
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()
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'])
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('/')
_lora_subfolder = os.path.basename(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')
return redirect(url_for('outfits_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('outfit_system.txt')
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')
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'):
continue
@@ -79,11 +91,11 @@ def register_routes(app):
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
html_path = os.path.join(clothing_lora_dir, html_filename)
# Read HTML companion file if it exists
html_path = os.path.join(clothing_lora_dir, f"{name_base}.html")
html_content = ""
if os.path.exists(html_path):
try:
@@ -94,27 +106,27 @@ def register_routes(app):
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML {html_filename}: {e}")
except Exception:
pass
try:
print(f"Asking LLM to describe outfit: {outfit_name}")
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
def make_task(fn, oid, oname, jp, lsf, html_ctx, sys_prompt, is_exist):
def task_fn(job):
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{fn}'"
if html_ctx:
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()
outfit_data = json.loads(clean_json)
outfit_data['outfit_id'] = outfit_id
outfit_data['outfit_name'] = outfit_name
outfit_data['outfit_id'] = oid
outfit_data['outfit_name'] = oname
if 'lora' not in outfit_data:
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'):
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:
outfit_data['lora']['lora_weight'] = 0.8
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:
outfit_data['lora']['lora_weight_max'] = 1.0
os.makedirs(app.config['CLOTHING_DIR'], exist_ok=True)
with open(json_path, 'w') as f:
os.makedirs(os.path.dirname(jp), exist_ok=True)
with open(jp, 'w') as f:
json.dump(outfit_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': oname, 'action': 'overwritten' if is_exist else 'created'}
return task_fn
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:
print(f"Error creating outfit for {filename}: {e}")
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_outfits()
msg = f'Successfully processed outfits: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No outfits created or overwritten. {skipped_count} existing entries found.')
job['result'] = {'synced': True}
_enqueue_task("Sync outfits DB", sync_task)
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'))
def _get_linked_characters_for_outfit(outfit):
@@ -232,9 +246,12 @@ def register_routes(app):
else:
new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list)
tags_raw = request.form.get('tags', '')
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'] = {
'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
flag_modified(outfit, "data")
@@ -409,12 +426,16 @@ def register_routes(app):
@app.route('/outfit/create', methods=['GET', 'POST'])
def create_outfit():
form_data = {}
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
prompt = request.form.get('prompt', '')
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
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -435,13 +456,13 @@ def register_routes(app):
if use_llm:
if not prompt:
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
system_prompt = load_prompt('outfit_system.txt')
if not system_prompt:
flash("System prompt file not found.")
return redirect(request.url)
return render_template('outfits/create.html', form_data=form_data)
try:
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:
print(f"LLM error: {e}")
flash(f"Failed to generate outfit profile: {e}")
return redirect(request.url)
return render_template('outfits/create.html', form_data=form_data)
else:
# Create blank outfit template
outfit_data = {
@@ -523,9 +544,9 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {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'])
def save_outfit_defaults(slug):
@@ -601,3 +622,12 @@ def register_routes(app):
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
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'},
'action': {'action_id': _entity_id(request.form.get('action_id')),
'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'))
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
'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'])
def create_preset():
form_data = {}
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
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_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
base_id = safe_id
@@ -265,7 +271,7 @@ def register_routes(app):
system_prompt = load_prompt('preset_system.txt')
if not system_prompt:
flash('Preset system prompt file not found.', 'error')
return redirect(request.url)
return render_template('presets/create.html', form_data=form_data)
try:
llm_response = call_llm(
f"Create a preset profile named '{name}' based on this description: {description}",
@@ -276,7 +282,7 @@ def register_routes(app):
except Exception as e:
logger.exception("LLM error creating preset: %s", e)
flash(f"AI generation failed: {e}", 'error')
return redirect(request.url)
return render_template('presets/create.html', form_data=form_data)
else:
preset_data = {
'character': {'character_id': 'random', 'use_lora': True,
@@ -314,7 +320,7 @@ def register_routes(app):
flash(f"Preset '{name}' created!")
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')
def get_missing_presets():

View File

@@ -1,10 +1,14 @@
import logging
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')
# Both queues for iteration
_ALL_QUEUES = (_job_queue, _llm_queue)
def register_routes(app):
@@ -12,23 +16,27 @@ def register_routes(app):
def api_queue_list():
"""Return the current queue as JSON."""
with _job_queue_lock:
jobs = [
{
jobs = []
for q in _ALL_QUEUES:
for j in q:
jobs.append({
'id': j['id'],
'label': j['label'],
'status': j['status'],
'error': j['error'],
'created_at': j['created_at'],
}
for j in _job_queue
]
'job_type': j.get('job_type', 'comfyui'),
})
return {'jobs': jobs, 'count': len(jobs)}
@app.route('/api/queue/count')
def api_queue_count():
"""Return just the count of active (non-done, non-failed) jobs."""
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}
@app.route('/api/queue/<job_id>/remove', methods=['POST'])
@@ -40,10 +48,12 @@ def register_routes(app):
return {'error': 'Job not found'}, 404
if job['status'] == 'processing':
return {'error': 'Cannot remove a job that is currently processing'}, 400
for q in _ALL_QUEUES:
try:
_job_queue.remove(job)
q.remove(job)
break
except ValueError:
pass # Already not in queue
continue
job['status'] = 'removed'
return {'status': 'ok'}
@@ -58,6 +68,10 @@ def register_routes(app):
job['status'] = 'paused'
elif job['status'] == 'paused':
job['status'] = 'pending'
# Signal the appropriate worker
if job.get('job_type') == 'llm':
_llm_worker_event.set()
else:
_queue_worker_event.set()
else:
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'])
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
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:
try:
_job_queue.remove(job)
q.remove(job)
job['status'] = 'removed'
removed_count += 1
except ValueError:
@@ -91,7 +106,8 @@ def register_routes(app):
'label': job['label'],
'status': job['status'],
'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'):
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 os
import re
import time
import logging
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 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.sync import sync_scenes
from services.file_io import get_available_loras
@@ -37,8 +36,17 @@ def register_routes(app):
@app.route('/scenes')
def scenes_index():
scenes = Scene.query.order_by(Scene.name).all()
return render_template('scenes/index.html', scenes=scenes)
query = Scene.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)
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'])
def rescan_scenes():
@@ -117,9 +125,12 @@ def register_routes(app):
else:
new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list)
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
# Update Tags (structured dict)
new_data['tags'] = {
'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
flag_modified(scene, "data")
@@ -332,15 +343,16 @@ def register_routes(app):
return redirect(url_for('scenes_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
skipped = 0
job_ids = []
system_prompt = load_prompt('scene_system.txt')
if not system_prompt:
flash('Scene system prompt file not found.', 'error')
return redirect(url_for('scenes_index'))
scenes_dir = app.config['SCENES_DIR']
for filename in os.listdir(backgrounds_lora_dir):
if filename.endswith('.safetensors'):
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()
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)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
@@ -362,28 +374,24 @@ def register_routes(app):
try:
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
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'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML {html_filename}: {e}")
logger.error("Error reading HTML %s: %s", html_filename, e)
try:
print(f"Asking LLM to describe scene: {scene_name}")
def make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
def task_fn(job):
prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'"
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###"
llm_response = call_llm(prompt, system_prompt)
# Clean response
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
scene_data = json.loads(clean_json)
# Enforce system values while preserving LLM-extracted metadata
scene_data['scene_id'] = scene_id
scene_data['scene_name'] = scene_name
@@ -402,34 +410,33 @@ def register_routes(app):
with open(json_path, 'w') as f:
json.dump(scene_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': scene_name, 'action': 'overwritten' if is_existing else 'created'}
return task_fn
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
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))
job_ids.append(job['id'])
except Exception as e:
print(f"Error creating scene for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
if job_ids:
def sync_task(job):
sync_scenes()
msg = f'Successfully processed scenes: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No scenes created or overwritten. {skipped_count} existing scenes found.')
job['result'] = {'synced': True}
_enqueue_task("Sync scenes DB", sync_task)
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'))
@app.route('/scene/create', methods=['GET', 'POST'])
def create_scene():
form_data = {}
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
form_data = {'name': name, 'filename': slug}
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -478,9 +485,9 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {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'])
def clone_scene(slug):
@@ -538,3 +545,12 @@ def register_routes(app):
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
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':
wardrobe = entity.data.get('wardrobe', {})
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]
char_parts = []
face_parts = []
@@ -83,7 +82,7 @@ def register_routes(app):
face_parts = [v for v in [identity.get('head'),
defaults.get('expression')] 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 {
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
'face': _dedup_tags(', '.join(face_parts)),
@@ -93,7 +92,6 @@ def register_routes(app):
if category == 'actions':
action_data = entity.data.get('action', {})
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
from utils import _BODY_GROUP_KEYS
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 []
@@ -104,7 +102,7 @@ def register_routes(app):
identity = character.data.get('identity', {})
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]
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 {
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
'face': _dedup_tags(', '.join(face_parts)),
@@ -113,20 +111,19 @@ def register_routes(app):
# styles / scenes / detailers
entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
if category == 'styles':
sdata = entity.data.get('style', {})
artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else ''
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':
sdata = entity.data.get('scene', {})
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
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)
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 re
import random
import time
import logging
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 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.sync import sync_styles
from services.file_io import get_available_loras
@@ -82,8 +81,17 @@ def register_routes(app):
@app.route('/styles')
def styles_index():
styles = Style.query.order_by(Style.name).all()
return render_template('styles/index.html', styles=styles)
query = Style.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)
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'])
def rescan_styles():
@@ -158,6 +166,13 @@ def register_routes(app):
else:
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
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('/')
_lora_subfolder = os.path.basename(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')
return redirect(url_for('styles_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('style_system.txt')
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')
return redirect(url_for('styles_index'))
for filename in os.listdir(styles_lora_dir):
if filename.endswith('.safetensors'):
job_ids = []
skipped = 0
for filename in sorted(os.listdir(styles_lora_dir)):
if not filename.endswith('.safetensors'):
continue
name_base = filename.rsplit('.', 1)[0]
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
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)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
html_path = os.path.join(styles_lora_dir, html_filename)
# Read HTML companion file if it exists
html_path = os.path.join(styles_lora_dir, f"{name_base}.html")
html_content = ""
if os.path.exists(html_path):
try:
@@ -382,27 +403,28 @@ def register_routes(app):
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML {html_filename}: {e}")
except Exception:
pass
try:
print(f"Asking LLM to describe style: {style_name}")
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{filename}'"
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###"
def make_task(fn, sid, sname, jp, lsf, html_ctx, sys_prompt, is_exist):
def task_fn(job):
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{fn}'"
if html_ctx:
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()
style_data = json.loads(clean_json)
style_data['style_id'] = style_id
style_data['style_name'] = style_name
style_data['style_id'] = sid
style_data['style_name'] = sname
if 'lora' not in style_data: style_data['lora'] = {}
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if 'lora' not in style_data:
style_data['lora'] = {}
style_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
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:
style_data['lora']['lora_weight'] = 1.0
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:
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)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': sname, 'action': 'overwritten' if is_exist else 'created'}
return task_fn
time.sleep(0.5)
except Exception as e:
print(f"Error creating style for {filename}: {e}")
job = _enqueue_task(
f"Create style: {style_name}",
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()
msg = f'Successfully processed styles: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No styles created or overwritten. {skipped_count} existing styles found.')
job['result'] = {'synced': True}
_enqueue_task("Sync styles DB", sync_task)
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'))
@app.route('/style/create', methods=['GET', 'POST'])
def create_style():
form_data = {}
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
form_data = {'name': name, 'filename': slug}
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -483,9 +513,9 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {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'])
def clone_style(slug):
@@ -542,3 +572,12 @@ def register_routes(app):
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
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')
def generate_from_preset(preset, overrides=None):
def generate_from_preset(preset, overrides=None, save_category='presets'):
"""Execute preset-based generation.
Args:
preset: Preset ORM object
overrides: optional dict with keys:
checkpoint, extra_positive, extra_negative, seed, width, height, action
save_category: upload sub-directory ('presets' or 'generator')
Returns:
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'))
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 = overrides.get('checkpoint', '').strip() if overrides.get('checkpoint') else ''
if checkpoint_override:
@@ -71,14 +85,31 @@ def generate_from_preset(preset, overrides=None):
else:
ckpt_path, ckpt_data = _get_default_checkpoint()
resolved_meta['checkpoint_path'] = ckpt_path
# Resolve selected fields from preset toggles
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
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
if wardrobe_source is None:
wardrobe_source = character.get_active_wardrobe() if character else {}
if suppress_wardrobe:
wardrobe_source = {}
combined_data = {
'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 {},
'lora': (look_obj.data.get('lora', {}) if look_obj
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
@@ -108,7 +138,6 @@ def generate_from_preset(preset, overrides=None):
trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
if trg:
extras_parts.append(trg)
extras_parts.extend(action_obj.data.get('tags', []))
if style_obj:
s = style_obj.data.get('style', {})
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', '')
if trg:
extras_parts.append(trg)
extras_parts.extend(scene_obj.data.get('tags', []))
if detailer_obj:
prompt_val = detailer_obj.data.get('prompt', '')
if isinstance(prompt_val, list):
@@ -195,6 +223,7 @@ def generate_from_preset(preset, overrides=None):
)
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

View File

@@ -1,3 +1,4 @@
import json
import os
import time
import uuid
@@ -27,8 +28,10 @@ logger = logging.getLogger('gaze')
_job_queue_lock = threading.Lock()
_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)
_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()
_app = None
@@ -39,6 +42,7 @@ def _enqueue_job(label, workflow, finalize_fn):
job = {
'id': str(uuid.uuid4()),
'label': label,
'job_type': 'comfyui',
'status': 'pending',
'workflow': workflow,
'finalize_fn': finalize_fn,
@@ -55,6 +59,26 @@ def _enqueue_job(label, workflow, finalize_fn):
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():
"""Background thread: processes jobs from _job_queue sequentially."""
while True:
@@ -174,13 +198,14 @@ def _queue_worker():
_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.
category — upload sub-directory name (e.g. 'characters', 'outfits')
slug — entity slug used for the upload folder name
db_model_class — SQLAlchemy model class for cover-image DB update; None = 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):
logger.debug("=" * 80)
@@ -212,6 +237,14 @@ def _make_finalize(category, slug, db_model_class=None, action=None):
f.write(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}"
# Include the seed used for this generation
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
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):
"""Remove completed/failed jobs older than max_age_seconds from _job_history."""
cutoff = time.time() - max_age_seconds
@@ -261,5 +339,5 @@ def init_queue_worker(flask_app):
"""
global _app
_app = flask_app
worker = threading.Thread(target=_queue_worker, daemon=True, name='queue-worker')
worker.start()
threading.Thread(target=_queue_worker, daemon=True, name='comfyui-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 asyncio
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.client.stdio import stdio_client
from models import Settings
@@ -77,6 +77,28 @@ def call_mcp_tool(name, arguments):
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):
path = os.path.join('data/prompts', filename)
if os.path.exists(path):
@@ -100,7 +122,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
headers = {
"Authorization": f"Bearer {settings.openrouter_api_key}",
"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"
}
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}
]
max_turns = 10
max_turns = 15
tool_turns_remaining = 8 # stop offering tools after this many tool-calling turns
use_tools = True
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,
}
# Only add tools if supported/requested
if use_tools:
# Only add tools if supported/requested and we haven't exhausted tool turns
if use_tools and tool_turns_remaining > 0:
data["tools"] = DANBOORU_TOOLS
data["tool_choice"] = "auto"
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 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')
if message.get('tool_calls'):
tool_turns_remaining -= 1
messages.append(message)
for tool_call in message['tool_calls']:
name = tool_call['function']['name']
@@ -170,6 +194,8 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
"name": name,
"content": tool_result
})
if tool_turns_remaining <= 0:
print("Tool turn limit reached — next request will not offer tools")
continue
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'):
parts.append(style_data['artistic_style'])
tags = data.get('tags', [])
if tags and is_selected('special', 'tags'):
parts.extend(tags)
lora = data.get('lora', {})
if lora.get('lora_triggers') and is_selected('lora', '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', {})
if lora.get('lora_triggers'):
parts.append(lora['lora_triggers'])
parts.extend(data.get('tags', []))
for key in _BODY_GROUP_KEYS:
val = data.get('action', {}).get(key)
if val:
@@ -299,7 +294,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
lora = data.get('lora', {})
if lora.get('lora_triggers'):
parts.append(lora['lora_triggers'])
parts.extend(data.get('tags', []))
for scene in scenes:
data = scene.data
@@ -311,7 +305,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
lora = data.get('lora', {})
if lora.get('lora_triggers'):
parts.append(lora['lora_triggers'])
parts.extend(data.get('tags', []))
for style in styles:
data = style.data

View File

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

View File

@@ -10,23 +10,23 @@
<form action="{{ url_for('create_action') }}" method="post">
<div class="mb-3">
<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 class="mb-3">
<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>
<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>
</div>
<div class="mb-3" id="prompt-group">
<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>

View File

@@ -111,33 +111,29 @@
</div>
</div>
{% set tags = action.data.tags if action.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<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-header bg-dark text-white"><span>Tags</span></div>
<div class="card-body">
{% for tag in action.data.tags %}
<span class="badge bg-secondary">{{ tag }}</span>
{% else %}
<span class="text-muted">No tags</span>
{% endfor %}
{% if tags.participants %}<span class="badge bg-info">{{ tags.participants }}</span>{% endif %}
{% if action.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if action.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<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>
<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>
@@ -145,6 +141,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<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('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
@@ -299,6 +296,16 @@
{% block scripts %}
<script>
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 progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');

View File

@@ -22,9 +22,24 @@
<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 }}">
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (comma separated)</label>
<input type="text" class="form-control" id="tags" name="tags" value="{{ action.data.tags | join(', ') }}">
{% set tags = action.data.tags if action.data.tags is mapping else {} %}
<div class="row">
<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>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Action Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents">
<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>
</form>
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="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>
</form>
<a href="{{ url_for('create_action') }}" class="btn btn-sm btn-success">Create New Action</a>
<form action="{{ url_for('rescan_actions') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan action files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
{{ library_toolbar(
title="Action",
category="actions",
create_url=url_for('create_action'),
create_label="Action",
has_batch_gen=true,
has_regen_all=true,
has_lora_create=true,
bulk_create_url=url_for('bulk_create_actions_from_loras'),
has_tags=true,
regen_tags_category="actions",
rescan_url=url_for('rescan_actions'),
get_missing_url="/get_missing_actions",
clear_covers_url="/clear_all_action_covers",
generate_url_pattern="/action/{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>
<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">
{% for action in actions %}
@@ -40,7 +52,7 @@
{% endif %}
</div>
<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">
{% set ns = namespace(parts=[]) %}
{% if action.data.action is mapping %}
@@ -80,111 +92,11 @@
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (card) 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 src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %}

View File

@@ -106,7 +106,12 @@
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<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 class="d-flex gap-2">
<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 %}
<script>
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 progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');

View File

@@ -1,23 +1,34 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Checkpoint Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents">
<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>
</form>
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<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>
</form>
<form action="{{ url_for('rescan_checkpoints') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan checkpoint files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
{% from "partials/library_toolbar.html" import library_toolbar %}
{{ library_toolbar(
title="Checkpoint",
category="checkpoints",
create_url=none,
has_batch_gen=true,
has_regen_all=true,
has_lora_create=true,
bulk_create_url=url_for('bulk_create_checkpoints'),
has_tags=false,
rescan_url=url_for('rescan_checkpoints'),
get_missing_url="/get_missing_checkpoints",
clear_covers_url="/clear_all_checkpoint_covers",
generate_url_pattern="/checkpoint/{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>
<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">
{% for ckpt in checkpoints %}
@@ -39,7 +50,10 @@
{% endif %}
</div>
<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 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>
@@ -53,101 +67,5 @@
{% endblock %}
{% block scripts %}
<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>
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %}

View File

@@ -10,26 +10,32 @@
<form action="{{ url_for('create_character') }}" method="post">
<div class="mb-3">
<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 class="mb-3">
<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>
<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>
</div>
<div class="mb-3" id="prompt-group">
<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>
<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">
<i class="bi bi-info-circle"></i> The AI will generate a complete character profile based on your description.
</div>
@@ -51,6 +57,7 @@
<script>
document.getElementById('use_llm').addEventListener('change', function() {
const promptGroup = document.getElementById('prompt-group');
const wikiUrlGroup = document.getElementById('wiki-url-group');
const aiInfo = document.getElementById('ai-info');
const manualInfo = document.getElementById('manual-info');
const submitBtn = document.getElementById('submit-btn');
@@ -58,12 +65,14 @@ document.getElementById('use_llm').addEventListener('change', function() {
if (this.checked) {
promptGroup.classList.remove('d-none');
wikiUrlGroup.classList.remove('d-none');
aiInfo.classList.remove('d-none');
manualInfo.classList.add('d-none');
submitBtn.textContent = 'Create & Generate';
promptInput.required = true;
} else {
promptGroup.classList.add('d-none');
wikiUrlGroup.classList.add('d-none');
aiInfo.classList.add('d-none');
manualInfo.classList.remove('d-none');
submitBtn.textContent = 'Create Character';

View File

@@ -59,31 +59,30 @@
</div>
</div>
{% set tags = character.data.tags if character.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<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-header bg-dark text-white"><span>Tags</span></div>
<div class="card-body">
{% for tag in character.data.tags %}
<span class="badge bg-secondary">{{ tag }}</span>
{% endfor %}
{% if tags.origin_series %}<span class="badge bg-info">{{ tags.origin_series }}</span>{% endif %}
{% if tags.origin_type %}<span class="badge bg-primary">{{ tags.origin_type }}</span>{% endif %}
{% 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>
{% endif %}
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<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">
<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">
@@ -91,8 +90,11 @@
</a>
</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>
</div>
</div>
<ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist">
<li class="nav-item" role="presentation">
@@ -271,6 +273,16 @@
{% block scripts %}
<script>
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 progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');

View File

@@ -10,13 +10,13 @@
<form action="{{ url_for('create_detailer') }}" method="post">
<div class="mb-4">
<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>
<div class="mb-4">
<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>

View File

@@ -127,13 +127,20 @@
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<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">
<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 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-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('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
@@ -260,6 +267,16 @@
{% block scripts %}
<script>
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 progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');

View File

@@ -74,11 +74,34 @@
value="{{ detailer.data.prompt or '' }}">
<div class="form-text">Comma-separated tags, e.g. "glossy eyes, detailed irises"</div>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Extra Tags</label>
<input type="text" class="form-control" id="tags" name="tags"
value="{{ detailer.data.tags | join(', ') if detailer.data.tags else '' }}">
<div class="form-text">Comma-separated extra tags appended to every generation.</div>
{% set tags = detailer.data.tags if detailer.data.tags is mapping else {} %}
<div class="row">
<div class="col-md-4 mb-3">
<label for="tag_associated_resource" class="form-label">Associated Resource</label>
<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>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Detailer Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents">
<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>
</form>
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="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>
</form>
<a href="{{ url_for('create_detailer') }}" class="btn btn-sm btn-success">Create New Detailer</a>
<form action="{{ url_for('rescan_detailers') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan detailer files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
{{ library_toolbar(
title="Detailer",
category="detailers",
create_url=url_for('create_detailer'),
create_label="Detailer",
has_batch_gen=true,
has_regen_all=true,
has_lora_create=true,
bulk_create_url=url_for('bulk_create_detailers_from_loras'),
has_tags=true,
regen_tags_category="detailers",
rescan_url=url_for('rescan_detailers'),
get_missing_url="/get_missing_detailers",
clear_covers_url="/clear_all_detailer_covers",
generate_url_pattern="/detailer/{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>
<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">
{% for detailer in detailers %}
@@ -40,7 +52,7 @@
{% endif %}
</div>
<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">
{% set ns = namespace(parts=[]) %}
{% if detailer.data.prompt is string %}
@@ -82,111 +94,11 @@
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (card) 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 src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %}

View File

@@ -18,9 +18,27 @@
<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>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (comma separated)</label>
<input type="text" class="form-control" id="tags" name="tags" value="{{ character.data.tags | join(', ') }}">
{% set tags = character.data.tags if character.data.tags is mapping else {} %}
<div class="row">
<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>
@@ -121,7 +139,7 @@
</button>
</div>
<div class="card-body">
{% set wardrobe_data = character.data.wardrobe %}
{% set wardrobe_data = character.data.get('wardrobe', {}) %}
{% set outfits = character.get_available_outfits() %}
{% if wardrobe_data.default is defined and wardrobe_data.default is mapping %}
{# New nested format - show tabs for each outfit #}

View File

@@ -57,23 +57,50 @@
</select>
</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 -->
<div class="col">
{% if category != 'all' %}
<span class="badge bg-primary me-1">
{{ category | capitalize }}
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
<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>
{% endif %}
{% if slug %}
<span class="badge bg-secondary me-1">
{{ slug }}
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
<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>
{% endif %}
</div>
<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>
</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">
Checkpoints
</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>
@@ -222,6 +260,9 @@
'styles': 'warning',
'detailers': 'secondary',
'checkpoints': 'dark',
'looks': 'primary',
'presets': 'purple',
'generator': 'teal',
} %}
{% for img in images %}
<div class="gallery-card"
@@ -237,6 +278,8 @@
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
{{ img.category[:-1] if img.category.endswith('s') else img.category }}
</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 -->
<div class="info-meta">
@@ -280,8 +323,12 @@
<a href="{{ url_for('checkpoint_detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
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 %}
<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"
onclick="event.stopPropagation()">Generator</a>
{% endif %}
@@ -376,6 +423,12 @@
<div class="meta-grid" id="metaGrid"></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">
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
</div>
@@ -409,7 +462,31 @@
{% endblock %}
{% 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>
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 ----
let promptModal;
document.addEventListener('DOMContentLoaded', () => {
@@ -473,15 +550,55 @@ async function showPrompt(imgPath, name, category, slug) {
document.getElementById('noMetaMsg').classList.toggle('d-none',
!!(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
const genUrl = category === 'characters'
? `/character/${slug}`
: category === 'checkpoints'
? `/checkpoint/${slug}`
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
let genUrl, genLabel;
if (category === 'characters') {
genUrl = `/character/${slug}`;
genLabel = 'Open';
} 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');
genBtn.href = genUrl;
genBtn.textContent = (category === 'characters' || category === 'checkpoints') ? 'Open' : 'Open in Generator';
genBtn.textContent = genLabel;
} catch (e) {
document.getElementById('promptPositive').value = 'Error loading metadata.';
} finally {

View File

@@ -6,15 +6,15 @@
<div class="col-md-5">
<div id="progress-container" class="mb-3 d-none">
<label id="progress-label" class="form-label">Generating...</label>
<div class="progress" role="progressbar" aria-label="Generation Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div class="progress" role="progressbar">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-primary text-white">Generator Settings</div>
<div class="card-header bg-primary text-white">Generator</div>
<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 -->
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
@@ -34,184 +34,94 @@
</div>
</div>
<!-- Character -->
<!-- Preset selector -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="character" class="form-label mb-0">Character</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-char-btn">Random</button>
<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-preset-btn">Random</button>
</div>
<select class="form-select" id="character" name="character" required>
<option value="" disabled {% if not selected_char %}selected{% endif %}>Select a character...</option>
{% for char in characters %}
<option value="{{ char.slug }}" {% if selected_char == char.slug %}selected{% endif %}>{{ char.name }}</option>
<select class="form-select" id="preset-select" name="preset_slug" required>
<option value="" disabled {% if not preset_slug %}selected{% endif %}>Select a preset...</option>
{% for p in presets %}
<option value="{{ p.slug }}" {% if preset_slug == p.slug %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</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="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>
</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 %}
<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 %}
</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>
<!-- Resolution -->
<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">
<button type="button" class="btn btn-sm btn-secondary preset-btn" data-w="1024" data-h="1024">1:1</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1152" data-h="896">4:3 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="896" data-h="1152">4:3 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1344" data-h="768">16:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1344">16:9 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1280" data-h="800">16:10 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="800" data-h="1280">16:10 P</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1792" data-h="768">21:9 L</button>
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1792">21:9 P</button>
<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 res-btn" data-w="1024" data-h="1024">1:1</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 res-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="1344" data-h="768">16:9 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 res-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="800" data-h="1280">16:10 P</button>
</div>
<div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 small fw-semibold">W</label>
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
value="1024" min="64" max="4096" step="64" style="width:88px">
<span class="text-muted">×</span>
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
<span class="text-muted">&times;</span>
<label class="form-label mb-0 small fw-semibold">H</label>
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
value="1024" min="64" max="4096" step="64" style="width:88px">
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
</div>
</div>
<!-- Prompt Preview -->
<!-- Extra prompts -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">Prompt Preview</label>
<div class="d-flex gap-1">
<button type="button" class="btn btn-sm btn-outline-primary" id="build-preview-btn">Build</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
</div>
</div>
<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>
<label for="extra_positive" class="form-label">Extra Positive Prompt</label>
<textarea class="form-control form-control-sm" id="extra_positive" name="extra_positive" rows="2"
placeholder="Additional tags appended to the preset prompt"></textarea>
</div>
<div class="mb-3">
<label class="form-label mb-0">Hand Detailer Prompt</label>
<div class="tag-widget-container d-none" id="hand-tags"></div>
<textarea class="form-control form-control-sm font-monospace" id="hand-preview"
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>
<label for="extra_negative" class="form-label">Extra Negative Prompt</label>
<textarea class="form-control form-control-sm" id="extra_negative" name="extra_negative" rows="2"
placeholder="Additional negative tags"></textarea>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card">
<div class="card-header bg-dark text-white">Result</div>
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" 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">
<p>Select settings and click Generate</p>
<p>Select a preset and click Generate</p>
</div>
<div class="img-container w-100 h-100 d-none">
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
</div>
{% endif %}
</div>
<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>
@@ -221,60 +131,43 @@
{% block scripts %}
<script>
// --- Filtering ---
function filterMixCategory(input, listId) {
const query = input.value.toLowerCase();
document.querySelectorAll(`#${listId} .mix-item`).forEach(el => {
el.style.display = el.dataset.name.includes(query) ? '' : 'none';
});
// --- Preset summary ---
async function loadPresetInfo(slug) {
const container = document.getElementById('preset-summary-container');
const summary = document.getElementById('preset-summary');
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);
}
function updateMixBadge(key, fieldName) {
const count = document.querySelectorAll(`input[name="${fieldName}"]:checked`).length;
const badge = document.getElementById(`badge-${key}`);
badge.textContent = count;
badge.className = count > 0
? 'badge bg-primary rounded-pill ms-2'
: 'badge bg-secondary rounded-pill ms-2';
container.style.display = '';
} catch (e) {
container.style.display = 'none';
}
// --- Randomizers (global so inline onclick can call them) ---
function randomizeCategory(fieldName, catKey) {
const cbs = Array.from(document.querySelectorAll(`input[name="${fieldName}"]`));
cbs.forEach(cb => cb.checked = false);
if (cbs.length) cbs[Math.floor(Math.random() * cbs.length)].checked = true;
updateMixBadge(catKey, fieldName);
}
function applyLuckyDip() {
const charOpts = Array.from(document.getElementById('character').options).filter(o => o.value);
if (charOpts.length)
document.getElementById('character').value = charOpts[Math.floor(Math.random() * charOpts.length)].value;
const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
if (ckptOpts.length)
document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
const presets = Array.from(document.querySelectorAll('.preset-btn'));
if (presets.length) presets[Math.floor(Math.random() * presets.length)].click();
[['action_slugs', 'action'], ['outfit_slugs', 'outfit'], ['scene_slugs', 'scene'],
['style_slugs', 'style'], ['detailer_slugs', 'detailer']].forEach(([field, key]) => {
randomizeCategory(field, key);
});
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 => {
document.querySelectorAll('.res-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.preset-btn').forEach(b => {
document.querySelectorAll('.res-btn').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
@@ -283,235 +176,149 @@
});
});
// Deselect res presets when manual input changes
['res-width', 'res-height'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
document.querySelectorAll('.preset-btn').forEach(b => {
document.querySelectorAll('.res-btn').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
});
});
// --- Tag Widget System ---
function populateTagWidgets(containerId, textareaId, promptStr) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
container.innerHTML = '';
if (!promptStr || !promptStr.trim()) {
container.classList.add('d-none');
return;
}
const tags = promptStr.split(',').map(t => t.trim()).filter(Boolean);
tags.forEach(tag => {
const el = document.createElement('span');
el.className = 'tag-widget active';
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) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
const activeTags = Array.from(container.querySelectorAll('.tag-widget.active'))
.map(el => el.dataset.tag);
textarea.value = activeTags.join(', ');
}
function clearTagWidgets(containerId, textareaId) {
const container = document.getElementById(containerId);
const textarea = document.getElementById(textareaId);
container.innerHTML = '';
container.classList.add('d-none');
textarea.classList.remove('d-none');
textarea.value = '';
}
// --- Prompt preview ---
async function buildPromptPreview() {
const charVal = document.getElementById('character').value;
const status = document.getElementById('preview-status');
if (!charVal) { status.textContent = 'Select a character first.'; return; }
status.textContent = 'Building...';
const formData = new FormData(document.getElementById('generator-form'));
try {
const resp = await fetch('/generator/preview_prompt', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) {
status.textContent = 'Error: ' + data.error;
} else {
document.getElementById('prompt-preview').value = data.prompt;
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 = '';
// --- Seed ---
document.getElementById('seed-clear-btn').addEventListener('click', () => {
document.getElementById('seed-input').value = '';
});
// --- 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();
progressCont.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLbl.textContent = label;
const fd = new FormData(form);
const resp = await fetch(form.action, {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
currentJobId = data.job_id;
progressLbl.textContent = 'Queued…';
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result && jobResult.result.image_url) {
resultImg.src = jobResult.result.image_url;
resultImg.parentElement.classList.remove('d-none');
if (placeholder) placeholder.classList.add('d-none');
resultFooter.classList.remove('d-none');
}
updateSeedFromResult(jobResult.result);
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
async function runLoop(endless) {
const total = endless ? Infinity : (parseInt(numInput.value) || 1);
stopRequested = false;
setGeneratingState(true);
let n = 0;
try {
while (!stopRequested && n < total) {
n++;
const lbl = endless ? `Generating #${n} (endless)...`
: total === 1 ? 'Starting...'
: `Generating ${n} / ${total}...`;
await runOne(lbl);
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
setGeneratingState(false);
}
}
form.addEventListener('submit', (e) => { e.preventDefault(); runLoop(false); });
endlessBtn.addEventListener('click', () => runLoop(true));
stopBtn.addEventListener('click', () => {
stopRequested = true;
progressLbl.textContent = 'Stopping after current image...';
});
document.getElementById('random-char-btn').addEventListener('click', () => {
const opts = Array.from(document.getElementById('character').options).filter(o => o.value);
// --- Random buttons ---
document.getElementById('random-preset-btn').addEventListener('click', () => {
const opts = Array.from(document.getElementById('preset-select').options).filter(o => o.value);
if (opts.length) {
document.getElementById('character').value = opts[Math.floor(Math.random() * opts.length)].value;
buildPromptPreview();
const pick = opts[Math.floor(Math.random() * opts.length)];
document.getElementById('preset-select').value = pick.value;
loadPresetInfo(pick.value);
}
});
document.getElementById('random-ckpt-btn').addEventListener('click', () => {
const opts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
if (opts.length)
if (opts.length) {
document.getElementById('checkpoint').value = opts[Math.floor(Math.random() * opts.length)].value;
}
});
document.getElementById('character').addEventListener('change', buildPromptPreview);
// --- Lucky Dip ---
function applyLuckyDip() {
document.getElementById('random-preset-btn').click();
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 resBtns = Array.from(document.querySelectorAll('.res-btn'));
if (resBtns.length) resBtns[Math.floor(Math.random() * resBtns.length)].click();
}
// 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;
// --- 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));
}
}
if (preselected) buildPromptPreview();
function setGeneratingState(active) {
document.getElementById('generate-btn').disabled = active;
document.getElementById('endless-btn').classList.toggle('d-none', active);
document.getElementById('stop-btn').classList.toggle('d-none', !active);
document.getElementById('progress-container').classList.toggle('d-none', !active);
}
function updateSeedFromResult(result) {
if (result && result.result && result.result.seed != null) {
document.getElementById('seed-input').value = result.result.seed;
}
}
async function runOne(label) {
if (document.getElementById('lucky-dip').checked) applyLuckyDip();
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = '100%';
progressBar.textContent = label;
document.getElementById('progress-label').textContent = label;
const form = document.getElementById('generator-form');
const formData = new FormData(form);
const res = await fetch(form.action, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData,
});
const data = await res.json();
if (data.error) throw new Error(data.error);
const result = await waitForJob(data.job_id);
if (result.result && result.result.image_url) {
const img = document.getElementById('result-img');
img.src = result.result.image_url + '?t=' + Date.now();
img.parentElement.classList.remove('d-none');
document.getElementById('placeholder-text')?.classList.add('d-none');
document.getElementById('result-footer').classList.remove('d-none');
}
updateSeedFromResult(result);
return result;
}
async function runLoop(endless) {
stopRequested = false;
setGeneratingState(true);
const total = endless ? Infinity : parseInt(document.getElementById('num-images').value) || 1;
let i = 0;
try {
while (i < total && !stopRequested) {
i++;
const label = endless
? `Generating (endless) #${i}...`
: total > 1
? `Generating ${i} / ${total}...`
: 'Generating...';
await runOne(label);
}
} catch (e) {
alert('Generation error: ' + e.message);
} finally {
setGeneratingState(false);
}
}
// --- Event listeners ---
document.getElementById('generator-form').addEventListener('submit', (e) => {
e.preventDefault();
runLoop(false);
});
document.getElementById('endless-btn').addEventListener('click', () => runLoop(true));
document.getElementById('stop-btn').addEventListener('click', () => { stopRequested = true; });
// --- Init: load preset info if pre-selected ---
document.addEventListener('DOMContentLoaded', () => {
const presetSelect = document.getElementById('preset-select');
if (presetSelect.value) loadPresetInfo(presetSelect.value);
});
</script>
{% endblock %}

View File

@@ -1,17 +1,35 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Character Library</h2>
<div class="d-flex gap-1 align-items-center">
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
<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>
<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>
<form action="{{ url_for('rescan') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan character files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
{% from "partials/library_toolbar.html" import library_toolbar %}
{{ library_toolbar(
title="Character",
category="characters",
create_url=url_for('create_character'),
create_label="Character",
has_batch_gen=true,
has_regen_all=true,
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>
<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>
</div>
<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 %}
@@ -33,7 +51,10 @@
{% endif %}
</div>
<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">
{% set ns = namespace(parts=[]) %}
{% for section_key in ['identity', 'defaults'] %}
@@ -58,12 +79,14 @@
{{ ns.parts | join(', ') }}
</p>
</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', '') %}
<div class="card-footer text-center p-1">
<small class="text-muted" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
<small class="text-muted text-truncate" 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>
{% endif %}
</div>
</div>
{% endfor %}
@@ -71,97 +94,5 @@
{% endblock %}
{% block scripts %}
<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>
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %}

View File

@@ -31,8 +31,13 @@
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
<div class="vr mx-1 d-none d-lg-block"></div>
<!-- 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 -->
<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 id="queue-count-badge" class="queue-badge d-none">0</span>
</button>
@@ -93,13 +98,13 @@
</div>
</div>
<!-- Generation Queue Modal -->
<!-- Job Queue Modal -->
<div class="modal fade" id="queueModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Generation Queue
Job Queue
<span id="queue-modal-count" class="badge bg-secondary ms-2">0</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
@@ -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) {
const jsonModal = document.getElementById('jsonEditorModal');
if (!jsonModal) return;
@@ -533,7 +565,7 @@
})();
</script>
<script>
// ---- Generation Queue UI ----
// ---- Job Queue UI ----
(function() {
const badge = document.getElementById('queue-count-badge');
const modalCount = document.getElementById('queue-modal-count');
@@ -575,7 +607,7 @@
queueBtn.title = `${pendingJobs.length} job(s) queued`;
} else {
queueBtn.classList.remove('queue-btn-generating');
queueBtn.title = 'Generation Queue';
queueBtn.title = 'Job Queue';
}
// Update modal count
@@ -616,6 +648,15 @@
statusDot.className = `queue-status-dot queue-status-${job.status}`;
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
const label = document.createElement('span');
label.className = 'flex-grow-1 small';

View File

@@ -15,20 +15,38 @@
<div class="card-body">
<div class="mb-3">
<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 class="mb-3">
<label for="character_id" class="form-label">Linked Character</label>
<select class="form-select" id="character_id" name="character_id">
<option value="">— None —</option>
{% 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 %}
</select>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (comma separated)</label>
<input type="text" class="form-control" id="tags" name="tags">
{% set tags = form_data.get('tags', {}) if form_data.get('tags') is mapping else {} %}
<div class="row">
<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>
@@ -43,18 +61,18 @@
<select class="form-select" id="lora_lora_name" name="lora_lora_name">
<option value="">None</option>
{% for lora in loras %}
<option value="{{ lora }}">{{ lora }}</option>
<option value="{{ lora }}" {{ 'selected' if form_data.get('lora_lora_name') == lora }}>{{ lora }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<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 class="mt-3">
<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>
@@ -65,11 +83,11 @@
<div class="card-body">
<div class="mb-3">
<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 class="mb-3">
<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>

View File

@@ -138,33 +138,30 @@
</div>
</div>
{% set tags = look.data.tags if look.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<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-header bg-dark text-white"><span>Tags</span></div>
<div class="card-body">
{% for tag in look.data.tags %}
<span class="badge bg-secondary">{{ tag }}</span>
{% else %}
<span class="text-muted">No tags</span>
{% endfor %}
{% if tags.origin_series %}<span class="badge bg-info">{{ tags.origin_series }}</span>{% endif %}
{% if tags.origin_type %}<span class="badge bg-primary">{{ tags.origin_type }}</span>{% endif %}
{% if look.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if look.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">{{ 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 %}
<small class="text-muted">
Linked to:
@@ -178,6 +175,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<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">
<i class="bi bi-person-plus"></i> Generate Character
</button>
@@ -281,6 +279,16 @@
{% block scripts %}
<script>
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 progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');

View File

@@ -39,9 +39,27 @@
</div>
<div class="form-text">Associates this look with multiple characters for generation and LoRA suggestions.</div>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (comma separated)</label>
<input type="text" class="form-control" id="tags" name="tags" value="{{ look.data.tags | join(', ') }}">
{% set tags = look.data.tags if look.data.tags is mapping else {} %}
<div class="row">
<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>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Looks Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents">
<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>
</form>
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<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>
</form>
<a href="{{ url_for('create_look') }}" class="btn btn-sm btn-success">Create New Look</a>
<form action="{{ url_for('rescan_looks') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan look files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
{% from "partials/library_toolbar.html" import library_toolbar %}
{{ library_toolbar(
title="Look",
category="looks",
create_url=url_for('create_look'),
create_label="Look",
has_batch_gen=true,
has_regen_all=true,
has_lora_create=true,
bulk_create_url=url_for('bulk_create_looks_from_loras'),
has_tags=true,
regen_tags_category="looks",
rescan_url=url_for('rescan_looks'),
get_missing_url="/get_missing_looks",
clear_covers_url="/clear_all_look_covers",
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>
<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">
{% for look in looks %}
@@ -43,7 +55,7 @@
{% endif %}
</div>
<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 %}
<p class="card-text small text-center text-info">{{ look.character_id.replace('_', ' ').title() }}</p>
{% endif %}
@@ -86,133 +98,11 @@
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (card) 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 src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %}

View File

@@ -10,23 +10,23 @@
<form action="{{ url_for('create_outfit') }}" method="post">
<div class="mb-3">
<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 class="mb-3">
<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>
<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>
</div>
<div class="mb-3" id="prompt-group">
<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>

View File

@@ -100,33 +100,29 @@
</div>
</div>
{% set tags = outfit.data.tags if outfit.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<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-header bg-dark text-white"><span>Tags</span></div>
<div class="card-body">
{% for tag in outfit.data.tags %}
<span class="badge bg-secondary">{{ tag }}</span>
{% else %}
<span class="text-muted">No tags</span>
{% endfor %}
{% if tags.outfit_type %}<span class="badge bg-info">{{ tags.outfit_type }}</span>{% endif %}
{% if outfit.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if outfit.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<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>
<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>
@@ -134,6 +130,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<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('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
@@ -280,6 +277,16 @@
{% block scripts %}
<script>
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 progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');

View File

@@ -22,9 +22,23 @@
<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 }}">
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (comma separated)</label>
<input type="text" class="form-control" id="tags" name="tags" value="{{ outfit.data.tags | join(', ') }}">
{% set tags = outfit.data.tags if outfit.data.tags is mapping else {} %}
<div class="row">
<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>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Outfit Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents">
<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>
</form>
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="true">
<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>
</form>
<a href="{{ url_for('create_outfit') }}" class="btn btn-sm btn-success">Create New Outfit</a>
<form action="{{ url_for('rescan_outfits') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan outfit files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
{% from "partials/library_toolbar.html" import library_toolbar %}
{{ library_toolbar(
title="Outfit",
category="outfits",
create_url=url_for('create_outfit'),
create_label="Outfit",
has_batch_gen=true,
has_regen_all=true,
has_lora_create=true,
bulk_create_url=url_for('bulk_create_outfits_from_loras'),
has_tags=true,
regen_tags_category="outfits",
rescan_url=url_for('rescan_outfits'),
get_missing_url="/get_missing_outfits",
clear_covers_url="/clear_all_outfit_covers",
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>
<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">
{% for outfit in outfits %}
@@ -43,7 +55,7 @@
{% endif %}
</div>
<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">
{% set ns = namespace(parts=[]) %}
{% if outfit.data.wardrobe is mapping %}
@@ -83,111 +95,12 @@
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (card) 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 src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% 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">
<div class="mb-3">
<label for="name" class="form-label">Preset Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual">
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual" value="{{ form_data.get('name', '') }}">
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
<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>
</div>
</div>
@@ -37,7 +37,7 @@
<textarea class="form-control" id="description" name="description" rows="5"
placeholder="Describe the kind of generations you want this preset to produce. Include the type of character, setting, mood, outfit style, etc.
Example: A random character in a cheerful beach scene, wearing a swimsuit, with a random action pose. Always include identity and wardrobe fields. Randomise headwear and accessories. No style LoRA needed."></textarea>
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>

View File

@@ -153,6 +153,20 @@
</div>
{% endfor %}
</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>

View File

@@ -10,13 +10,13 @@
<form action="{{ url_for('create_scene') }}" method="post">
<div class="mb-4">
<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>
<div class="mb-4">
<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>

View File

@@ -116,7 +116,13 @@
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<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">
<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>
@@ -127,6 +133,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<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('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
@@ -266,6 +273,16 @@
{% block scripts %}
<script>
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 progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');

View File

@@ -106,11 +106,23 @@
<div class="card mb-4">
<div class="card-header bg-light"><strong>Tags</strong></div>
<div class="card-body">
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<input type="text" class="form-control" id="tags" name="tags"
value="{{ scene.data.tags | join(', ') if scene.data.tags else '' }}">
<div class="form-text">Comma-separated tags appended to every generation.</div>
{% set tags = scene.data.tags if scene.data.tags is mapping else {} %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="tag_scene_type" class="form-label">Scene Type</label>
<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>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Scene Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents">
<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>
</form>
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="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>
</form>
<a href="{{ url_for('create_scene') }}" class="btn btn-sm btn-success">Create New Scene</a>
<form action="{{ url_for('rescan_scenes') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan scene files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
{{ library_toolbar(
title="Scene",
category="scenes",
create_url=url_for('create_scene'),
create_label="Scene",
has_batch_gen=true,
has_regen_all=true,
has_lora_create=true,
bulk_create_url=url_for('bulk_create_scenes_from_loras'),
has_tags=true,
regen_tags_category="scenes",
rescan_url=url_for('rescan_scenes'),
get_missing_url="/get_missing_scenes",
clear_covers_url="/clear_all_scene_covers",
generate_url_pattern="/scene/{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>
<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">
{% for scene in scenes %}
@@ -40,7 +52,7 @@
{% endif %}
</div>
<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">
{% set ns = namespace(parts=[]) %}
{% if scene.data.scene is mapping %}
@@ -80,110 +92,11 @@
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (card) 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 src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% 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>
<!-- 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>
{% 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>
{% endblock %}

View File

@@ -10,13 +10,13 @@
<form action="{{ url_for('create_style') }}" method="post">
<div class="mb-4">
<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>
<div class="mb-4">
<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>

View File

@@ -116,7 +116,13 @@
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-3">
<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">
<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>
@@ -127,6 +133,7 @@
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
<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('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
</div>
@@ -258,6 +265,16 @@
{% block scripts %}
<script>
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 progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');

View File

@@ -81,6 +81,31 @@
</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">
<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>

View File

@@ -1,24 +1,36 @@
{% extends "layout.html" %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Style Library</h2>
<div class="d-flex gap-1 align-items-center">
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents">
<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>
</form>
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents">
<input type="hidden" name="overwrite" value="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>
</form>
<a href="{{ url_for('create_style') }}" class="btn btn-sm btn-success">Create New Style</a>
<form action="{{ url_for('rescan_styles') }}" method="post" class="d-contents">
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan style files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
</form>
</div>
{{ library_toolbar(
title="Style",
category="styles",
create_url=url_for('create_style'),
create_label="Style",
has_batch_gen=true,
has_regen_all=true,
has_lora_create=true,
bulk_create_url=url_for('bulk_create_styles_from_loras'),
has_tags=true,
regen_tags_category="styles",
rescan_url=url_for('rescan_styles'),
get_missing_url="/get_missing_styles",
clear_covers_url="/clear_all_style_covers",
generate_url_pattern="/style/{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>
<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">
{% for style in styles %}
@@ -40,7 +52,7 @@
{% endif %}
</div>
<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">
{% set ns = namespace(parts=[]) %}
{% if style.data.style is mapping %}
@@ -80,111 +92,11 @@
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle highlight parameter
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
if (highlightSlug) {
const card = document.getElementById(`card-${highlightSlug}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (card) 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 src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %}