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:
98
CLAUDE.md
98
CLAUDE.md
@@ -46,6 +46,8 @@ routes/
|
|||||||
transfer.py # Resource transfer system
|
transfer.py # Resource transfer system
|
||||||
queue_api.py # /api/queue/* endpoints
|
queue_api.py # /api/queue/* endpoints
|
||||||
api.py # REST API v1 (preset generation, auth)
|
api.py # REST API v1 (preset generation, auth)
|
||||||
|
regenerate.py # Tag regeneration (single + bulk, via LLM queue)
|
||||||
|
search.py # Global search across resources and gallery images
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependency Graph
|
### Dependency Graph
|
||||||
@@ -90,6 +92,8 @@ All category models (except Settings and Checkpoint) share this pattern:
|
|||||||
- `data` — full JSON blob (SQLAlchemy JSON column)
|
- `data` — full JSON blob (SQLAlchemy JSON column)
|
||||||
- `default_fields` — list of `section::key` strings saved as the user's preferred prompt fields
|
- `default_fields` — list of `section::key` strings saved as the user's preferred prompt fields
|
||||||
- `image_path` — relative path under `static/uploads/`
|
- `image_path` — relative path under `static/uploads/`
|
||||||
|
- `is_favourite` — boolean (DB-only, not in JSON; toggled from detail pages)
|
||||||
|
- `is_nsfw` — boolean (mirrored in both DB column and JSON `tags.nsfw`; synced on rescan)
|
||||||
|
|
||||||
### Data Flow: JSON → DB → Prompt → ComfyUI
|
### Data Flow: JSON → DB → Prompt → ComfyUI
|
||||||
|
|
||||||
@@ -156,10 +160,16 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
|
|||||||
|
|
||||||
### `services/job_queue.py` — Background Job Queue
|
### `services/job_queue.py` — Background Job Queue
|
||||||
|
|
||||||
- **`_enqueue_job(label, workflow, finalize_fn)`** — Adds a generation job to the queue.
|
Two independent queues with separate worker threads:
|
||||||
- **`_make_finalize(category, slug, db_model_class=None, action=None)`** — Factory returning a callback that retrieves the generated image from ComfyUI, saves it, and optionally updates the DB cover image.
|
|
||||||
|
- **ComfyUI queue** (`_job_queue` + `_queue_worker`): Image generation jobs.
|
||||||
|
- **`_enqueue_job(label, workflow, finalize_fn)`** — Adds a generation job to the queue.
|
||||||
|
- **`_make_finalize(category, slug, db_model_class=None, action=None)`** — Factory returning a callback that retrieves the generated image from ComfyUI, saves it, and optionally updates the DB cover image.
|
||||||
|
- **LLM queue** (`_llm_queue` + `_llm_queue_worker`): LLM task jobs (tag regeneration, bulk create with overwrite).
|
||||||
|
- **`_enqueue_task(label, task_fn)`** — Adds an LLM task job. `task_fn` receives the job dict and runs inside `app.app_context()`.
|
||||||
|
- **Shared**: Both queues share `_job_history` (for status lookup by job ID) and `_job_queue_lock`.
|
||||||
- **`_prune_job_history(max_age_seconds=3600)`** — Removes old terminal-state jobs from memory.
|
- **`_prune_job_history(max_age_seconds=3600)`** — Removes old terminal-state jobs from memory.
|
||||||
- **`init_queue_worker(flask_app)`** — Stores the app reference and starts the worker thread.
|
- **`init_queue_worker(flask_app)`** — Stores the app reference and starts both worker threads.
|
||||||
|
|
||||||
### `services/comfyui.py` — ComfyUI HTTP Client
|
### `services/comfyui.py` — ComfyUI HTTP Client
|
||||||
|
|
||||||
@@ -170,13 +180,14 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
|
|||||||
|
|
||||||
### `services/llm.py` — LLM Integration
|
### `services/llm.py` — LLM Integration
|
||||||
|
|
||||||
- **`call_llm(prompt, system_prompt)`** — OpenAI-compatible chat completion supporting OpenRouter (cloud) and Ollama/LMStudio (local). Implements a tool-calling loop (up to 10 turns) using `DANBOORU_TOOLS` via MCP Docker container.
|
- **`call_llm(prompt, system_prompt)`** — OpenAI-compatible chat completion supporting OpenRouter (cloud) and Ollama/LMStudio (local). Implements a tool-calling loop (up to 10 turns) using `DANBOORU_TOOLS` via MCP Docker container. Safe to call from background threads (uses `has_request_context()` fallback for OpenRouter HTTP-Referer header).
|
||||||
- **`load_prompt(filename)`** — Loads system prompt text from `data/prompts/`.
|
- **`load_prompt(filename)`** — Loads system prompt text from `data/prompts/`.
|
||||||
- **`call_mcp_tool()`** — Synchronous wrapper for MCP tool calls.
|
- **`call_mcp_tool()`** — Synchronous wrapper for MCP tool calls.
|
||||||
|
|
||||||
### `services/sync.py` — Data Synchronization
|
### `services/sync.py` — Data Synchronization
|
||||||
|
|
||||||
- **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category.
|
- **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category.
|
||||||
|
- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called in every sync function on both create and update paths.
|
||||||
- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers.
|
- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers.
|
||||||
|
|
||||||
### `services/file_io.py` — File & DB Helpers
|
### `services/file_io.py` — File & DB Helpers
|
||||||
@@ -203,7 +214,9 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
|||||||
- `routes/checkpoints.py`: `_build_checkpoint_workflow()` — checkpoint-specific workflow builder
|
- `routes/checkpoints.py`: `_build_checkpoint_workflow()` — checkpoint-specific workflow builder
|
||||||
- `routes/strengths.py`: `_build_strengths_prompts()`, `_prepare_strengths_workflow()` — strengths gallery helpers
|
- `routes/strengths.py`: `_build_strengths_prompts()`, `_prepare_strengths_workflow()` — strengths gallery helpers
|
||||||
- `routes/transfer.py`: `_create_minimal_template()` — transfer template builder
|
- `routes/transfer.py`: `_create_minimal_template()` — transfer template builder
|
||||||
- `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()`
|
- `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()`, `_write_sidecar()` — gallery image sidecar JSON I/O
|
||||||
|
- `routes/regenerate.py`: Tag regeneration routes (single + category bulk + all), tag migration
|
||||||
|
- `routes/search.py`: `_search_resources()`, `_search_images()` — global search across resources and gallery
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -221,7 +234,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
|||||||
},
|
},
|
||||||
"styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" },
|
"styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" },
|
||||||
"lora": { "lora_name": "Illustrious/Looks/tifa.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
|
"lora": { "lora_name": "Illustrious/Looks/tifa.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
|
||||||
"tags": [],
|
"tags": { "origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false },
|
||||||
"participants": { "orientation": "1F", "solo_focus": "true" }
|
"participants": { "orientation": "1F", "solo_focus": "true" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -234,7 +247,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
|||||||
"outfit_name": "French Maid",
|
"outfit_name": "French Maid",
|
||||||
"wardrobe": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "accessories": "" },
|
"wardrobe": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "accessories": "" },
|
||||||
"lora": { "lora_name": "Illustrious/Clothing/maid.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
|
"lora": { "lora_name": "Illustrious/Clothing/maid.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
|
||||||
"tags": []
|
"tags": { "outfit_type": "Uniform", "nsfw": false }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -245,7 +258,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
|||||||
"action_name": "Sitting",
|
"action_name": "Sitting",
|
||||||
"action": { "full_body": "", "additional": "", "head": "", "eyes": "", "arms": "", "hands": "" },
|
"action": { "full_body": "", "additional": "", "head": "", "eyes": "", "arms": "", "hands": "" },
|
||||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||||
"tags": []
|
"tags": { "participants": "1girl", "nsfw": false }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -256,7 +269,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
|||||||
"scene_name": "Beach",
|
"scene_name": "Beach",
|
||||||
"scene": { "background": "", "foreground": "", "furniture": "", "colors": "", "lighting": "", "theme": "" },
|
"scene": { "background": "", "foreground": "", "furniture": "", "colors": "", "lighting": "", "theme": "" },
|
||||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||||
"tags": []
|
"tags": { "scene_type": "Outdoor", "nsfw": false }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -266,7 +279,8 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
|||||||
"style_id": "watercolor",
|
"style_id": "watercolor",
|
||||||
"style_name": "Watercolor",
|
"style_name": "Watercolor",
|
||||||
"style": { "artist_name": "", "artistic_style": "" },
|
"style": { "artist_name": "", "artistic_style": "" },
|
||||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }
|
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||||
|
"tags": { "style_type": "Watercolor", "nsfw": false }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -277,7 +291,8 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
|||||||
"detailer_name": "Detailed Skin",
|
"detailer_name": "Detailed Skin",
|
||||||
"prompt": ["detailed skin", "pores"],
|
"prompt": ["detailed skin", "pores"],
|
||||||
"focus": { "face": true, "hands": true },
|
"focus": { "face": true, "hands": true },
|
||||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }
|
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||||
|
"tags": { "associated_resource": "skin", "adetailer_targets": ["face", "hands"], "nsfw": false }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -290,7 +305,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
|||||||
"positive": "casual clothes, jeans",
|
"positive": "casual clothes, jeans",
|
||||||
"negative": "revealing",
|
"negative": "revealing",
|
||||||
"lora": { "lora_name": "Illustrious/Looks/tifa_casual.safetensors", "lora_weight": 0.85, "lora_triggers": "" },
|
"lora": { "lora_name": "Illustrious/Looks/tifa_casual.safetensors", "lora_weight": 0.85, "lora_triggers": "" },
|
||||||
"tags": []
|
"tags": { "origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
Looks occupy LoRA node 16, overriding the character's own LoRA. The Look's `negative` is prepended to the workflow's negative prompt.
|
Looks occupy LoRA node 16, overriding the character's own LoRA. The Look's `negative` is prepended to the workflow's negative prompt.
|
||||||
@@ -329,13 +344,14 @@ Checkpoint JSONs are keyed by `checkpoint_path`. If no JSON exists for a discove
|
|||||||
|
|
||||||
### Category Pattern (Outfits, Actions, Styles, Scenes, Detailers)
|
### Category Pattern (Outfits, Actions, Styles, Scenes, Detailers)
|
||||||
Each category follows the same URL pattern:
|
Each category follows the same URL pattern:
|
||||||
- `GET /<category>/` — gallery
|
- `GET /<category>/` — library with favourite/NSFW filter controls
|
||||||
- `GET /<category>/<slug>` — detail + generation UI
|
- `GET /<category>/<slug>` — detail + generation UI
|
||||||
- `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}`
|
- `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}`
|
||||||
- `POST /<category>/<slug>/replace_cover_from_preview`
|
- `POST /<category>/<slug>/replace_cover_from_preview`
|
||||||
- `GET/POST /<category>/<slug>/edit`
|
- `GET/POST /<category>/<slug>/edit`
|
||||||
- `POST /<category>/<slug>/upload`
|
- `POST /<category>/<slug>/upload`
|
||||||
- `POST /<category>/<slug>/save_defaults`
|
- `POST /<category>/<slug>/save_defaults`
|
||||||
|
- `POST /<category>/<slug>/favourite` — toggle `is_favourite` (AJAX)
|
||||||
- `POST /<category>/<slug>/clone` — duplicate entry
|
- `POST /<category>/<slug>/clone` — duplicate entry
|
||||||
- `POST /<category>/<slug>/save_json` — save raw JSON (from modal editor)
|
- `POST /<category>/<slug>/save_json` — save raw JSON (from modal editor)
|
||||||
- `POST /<category>/rescan`
|
- `POST /<category>/rescan`
|
||||||
@@ -375,6 +391,19 @@ All generation routes use the background job queue. Frontend polls:
|
|||||||
|
|
||||||
Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes.
|
Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes.
|
||||||
|
|
||||||
|
### Search
|
||||||
|
- `GET /search` — global search page; query params: `q` (search term), `category` (all/characters/outfits/etc.), `nsfw` (all/sfw/nsfw), `type` (all/resources/images)
|
||||||
|
|
||||||
|
### Tag Regeneration
|
||||||
|
- `POST /api/<category>/<slug>/regenerate_tags` — single entity tag regeneration via LLM queue
|
||||||
|
- `POST /admin/bulk_regenerate_tags/<category>` — queue LLM tag regeneration for all entities in a category
|
||||||
|
- `POST /admin/bulk_regenerate_tags` — queue LLM tag regeneration for all resources across all categories
|
||||||
|
- `POST /admin/migrate_tags` — convert old list-format tags to new dict format
|
||||||
|
|
||||||
|
### Gallery Image Metadata
|
||||||
|
- `POST /gallery/image/favourite` — toggle favourite on a gallery image (writes sidecar JSON)
|
||||||
|
- `POST /gallery/image/nsfw` — toggle NSFW on a gallery image (writes sidecar JSON)
|
||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
- `POST /set_default_checkpoint` — save default checkpoint to session and persist to `comfy_workflow.json`
|
- `POST /set_default_checkpoint` — save default checkpoint to session and persist to `comfy_workflow.json`
|
||||||
- `GET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}` — AJAX: list items without cover images (sorted by display name)
|
- `GET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}` — AJAX: list items without cover images (sorted by display name)
|
||||||
@@ -415,8 +444,10 @@ Text files in `data/prompts/` define JSON output schemas for LLM-generated entri
|
|||||||
- `character_system.txt` — character JSON schema
|
- `character_system.txt` — character JSON schema
|
||||||
- `outfit_system.txt` — outfit JSON schema
|
- `outfit_system.txt` — outfit JSON schema
|
||||||
- `action_system.txt`, `scene_system.txt`, `style_system.txt`, `detailer_system.txt`, `look_system.txt`, `checkpoint_system.txt`
|
- `action_system.txt`, `scene_system.txt`, `style_system.txt`, `detailer_system.txt`, `look_system.txt`, `checkpoint_system.txt`
|
||||||
|
- `preset_system.txt` — preset JSON schema
|
||||||
|
- `regenerate_tags_system.txt` — tag regeneration schema (all per-category tag structures)
|
||||||
|
|
||||||
Used by: character/outfit/action/scene/style create forms, and bulk_create routes.
|
Used by: character/outfit/action/scene/style create forms, bulk_create routes, and tag regeneration. All system prompts include NSFW awareness preamble.
|
||||||
|
|
||||||
### Danbooru MCP Tools
|
### Danbooru MCP Tools
|
||||||
The LLM loop in `call_llm()` provides three tools via a Docker-based MCP server (`danbooru-mcp:latest`):
|
The LLM loop in `call_llm()` provides three tools via a Docker-based MCP server (`danbooru-mcp:latest`):
|
||||||
@@ -430,6 +461,41 @@ All system prompts (`character_system.txt`, `outfit_system.txt`, `action_system.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Tagging System
|
||||||
|
|
||||||
|
Tags are **semantic metadata** for organizing and filtering resources. They are **not injected into generation prompts** — tags are purely for the UI (search, filtering, categorization).
|
||||||
|
|
||||||
|
### Tag Schema Per Category
|
||||||
|
|
||||||
|
| Category | Tag fields | Example |
|
||||||
|
|----------|-----------|---------|
|
||||||
|
| Character | `origin_series`, `origin_type`, `nsfw` | `{"origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false}` |
|
||||||
|
| Look | `origin_series`, `origin_type`, `nsfw` | same as Character |
|
||||||
|
| Outfit | `outfit_type`, `nsfw` | `{"outfit_type": "Uniform", "nsfw": false}` |
|
||||||
|
| Action | `participants`, `nsfw` | `{"participants": "1girl, 1boy", "nsfw": true}` |
|
||||||
|
| Style | `style_type`, `nsfw` | `{"style_type": "Anime", "nsfw": false}` |
|
||||||
|
| Scene | `scene_type`, `nsfw` | `{"scene_type": "Indoor", "nsfw": false}` |
|
||||||
|
| Detailer | `associated_resource`, `adetailer_targets`, `nsfw` | `{"associated_resource": "skin", "adetailer_targets": ["face", "hands"], "nsfw": false}` |
|
||||||
|
| Checkpoint | `art_style`, `base_model`, `nsfw` | `{"art_style": "anime", "base_model": "Illustrious", "nsfw": false}` |
|
||||||
|
|
||||||
|
### Favourite / NSFW Columns
|
||||||
|
|
||||||
|
- `is_favourite` — DB-only boolean. Toggled via `POST /<category>/<slug>/favourite`. Not stored in JSON (user preference, not asset metadata).
|
||||||
|
- `is_nsfw` — DB column **and** `tags.nsfw` in JSON. Synced from JSON on rescan via `_sync_nsfw_from_tags()`. Editable from edit pages.
|
||||||
|
|
||||||
|
### Library Filtering
|
||||||
|
|
||||||
|
All library index pages support query params:
|
||||||
|
- `?favourite=on` — show only favourites
|
||||||
|
- `?nsfw=sfw|nsfw|all` — filter by NSFW status
|
||||||
|
- Results are ordered by `is_favourite DESC, name ASC` (favourites sort first).
|
||||||
|
|
||||||
|
### Gallery Image Sidecar Files
|
||||||
|
|
||||||
|
Gallery images can have per-image favourite/NSFW metadata stored in sidecar JSON files at `{image_path}.json` (e.g. `static/uploads/characters/tifa/gen_123.png.json`). Sidecar schema: `{"is_favourite": bool, "is_nsfw": bool}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## LoRA File Paths
|
## LoRA File Paths
|
||||||
|
|
||||||
LoRA filenames in JSON are stored as paths relative to ComfyUI's `models/lora/` root:
|
LoRA filenames in JSON are stored as paths relative to ComfyUI's `models/lora/` root:
|
||||||
@@ -526,5 +592,7 @@ Volumes mounted into the app container:
|
|||||||
- **LoRA chaining**: If a LoRA node has no LoRA (name is empty/None), the node is skipped and `model_source`/`clip_source` pass through unchanged. Do not set the node inputs for skipped nodes.
|
- **LoRA chaining**: If a LoRA node has no LoRA (name is empty/None), the node is skipped and `model_source`/`clip_source` pass through unchanged. Do not set the node inputs for skipped nodes.
|
||||||
- **AJAX detection**: `request.headers.get('X-Requested-With') == 'XMLHttpRequest'` determines whether to return JSON or redirect.
|
- **AJAX detection**: `request.headers.get('X-Requested-With') == 'XMLHttpRequest'` determines whether to return JSON or redirect.
|
||||||
- **Session must be marked modified for JSON responses**: After setting session values in AJAX-responding routes, set `session.modified = True`.
|
- **Session must be marked modified for JSON responses**: After setting session values in AJAX-responding routes, set `session.modified = True`.
|
||||||
- **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. When merging into `tags` for `build_prompt`, use `extend` for lists and `append` for strings — never append the list object itself or `", ".join()` will fail on the nested list item.
|
- **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. In generate routes, the detailer prompt is injected directly into `prompts['main']` after `build_prompt()` returns (not via tags or `build_prompt` itself).
|
||||||
- **`_make_finalize` action semantics**: Pass `action=None` when the route should always update the DB cover (e.g. batch generate, checkpoint generate). Pass `action=request.form.get('action')` for routes that support both "preview" (no DB update) and "replace" (update DB). The factory skips the DB write when `action` is truthy and not `"replace"`.
|
- **`_make_finalize` action semantics**: Pass `action=None` when the route should always update the DB cover (e.g. batch generate, checkpoint generate). Pass `action=request.form.get('action')` for routes that support both "preview" (no DB update) and "replace" (update DB). The factory skips the DB write when `action` is truthy and not `"replace"`.
|
||||||
|
- **LLM queue runs without request context**: `_enqueue_task()` callbacks execute in a background thread with only `app.app_context()`. Do not access `flask.request`, `flask.session`, or other request-scoped objects inside `task_fn`. Use `has_request_context()` guard if code is shared between HTTP handlers and background tasks.
|
||||||
|
- **Tags are metadata only**: Tags (`data['tags']`) are never injected into generation prompts. They are purely for UI filtering and search. The old pattern of `parts.extend(data.get('tags', []))` in prompt building has been removed.
|
||||||
|
|||||||
14
app.py
14
app.py
@@ -119,6 +119,20 @@ if __name__ == '__main__':
|
|||||||
else:
|
else:
|
||||||
print(f"Migration settings note ({col_name}): {e}")
|
print(f"Migration settings note ({col_name}): {e}")
|
||||||
|
|
||||||
|
# Migration: Add is_favourite and is_nsfw columns to all resource tables
|
||||||
|
_tag_tables = ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint']
|
||||||
|
for _tbl in _tag_tables:
|
||||||
|
for _col, _type in [('is_favourite', 'BOOLEAN DEFAULT 0'), ('is_nsfw', 'BOOLEAN DEFAULT 0')]:
|
||||||
|
try:
|
||||||
|
db.session.execute(text(f'ALTER TABLE {_tbl} ADD COLUMN {_col} {_type}'))
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Added {_col} column to {_tbl} table")
|
||||||
|
except Exception as e:
|
||||||
|
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print(f"Migration note ({_tbl}.{_col}): {e}")
|
||||||
|
|
||||||
# Ensure settings exist
|
# Ensure settings exist
|
||||||
if not Settings.query.first():
|
if not Settings.query.first():
|
||||||
db.session.add(Settings())
|
db.session.add(Settings())
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Structure:
|
|||||||
"feet": "string (foot position)",
|
"feet": "string (foot position)",
|
||||||
"additional": "string (extra details)"
|
"additional": "string (extra details)"
|
||||||
},
|
},
|
||||||
|
"suppress_wardrobe": false,
|
||||||
"lora": {
|
"lora": {
|
||||||
"lora_name": "WILL_BE_REPLACED",
|
"lora_name": "WILL_BE_REPLACED",
|
||||||
"lora_weight": 1.0,
|
"lora_weight": 1.0,
|
||||||
@@ -28,8 +29,16 @@ Structure:
|
|||||||
"lora_weight_max": 1.0,
|
"lora_weight_max": 1.0,
|
||||||
"lora_triggers": "WILL_BE_REPLACED"
|
"lora_triggers": "WILL_BE_REPLACED"
|
||||||
},
|
},
|
||||||
"tags": ["string", "string"]
|
"tags": {
|
||||||
|
"participants": "string (e.g. 'solo', '1girl 1boy', '2girls', '3girls 1boy')",
|
||||||
|
"nsfw": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for sexual, explicit, or fetish actions.
|
||||||
|
|
||||||
|
- `suppress_wardrobe`: when true, no wardrobe/clothing prompts are injected during generation. Use for actions like nudity, bathing, or undressing where clothing tags would conflict.
|
||||||
|
|
||||||
Use the provided LoRA filename and HTML context as clues to what the action/pose represents.
|
Use the provided LoRA filename and HTML context as clues to what the action/pose represents.
|
||||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
||||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ Structure:
|
|||||||
"lora_weight_max": 1.0,
|
"lora_weight_max": 1.0,
|
||||||
"lora_triggers": ""
|
"lora_triggers": ""
|
||||||
},
|
},
|
||||||
"tags": ["string", "string"]
|
"tags": {
|
||||||
|
"origin_series": "string (the franchise/series the character is from, e.g. 'Fire Emblem', 'Spy x Family', 'Mario'. Use 'Original' if the character is not from any series)",
|
||||||
|
"origin_type": "string (one of: Anime, Video Game, Cartoon, Movie, Comic, Original)",
|
||||||
|
"nsfw": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the tags.
|
|
||||||
|
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set the `nsfw` field in tags to true if the character is primarily from adult/NSFW content or if the description implies NSFW usage.
|
||||||
|
|
||||||
|
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the prompt field tags (identity, wardrobe, etc). The `tags` object contains semantic metadata — not Danbooru tags.
|
||||||
|
|||||||
@@ -16,9 +16,16 @@ Structure:
|
|||||||
"steps": 25,
|
"steps": 25,
|
||||||
"cfg": 5.0,
|
"cfg": 5.0,
|
||||||
"sampler_name": "euler_ancestral",
|
"sampler_name": "euler_ancestral",
|
||||||
"vae": "integrated"
|
"vae": "integrated",
|
||||||
|
"tags": {
|
||||||
|
"art_style": "string (one of: Anime, Realistic, Cartoon, Semi-Realistic)",
|
||||||
|
"base_model": "string (one of: Illustrious, Noob — determined from the checkpoint path)",
|
||||||
|
"nsfw": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the checkpoint is specifically designed for NSFW content. Determine `base_model` from the checkpoint path (e.g. 'Illustrious/model.safetensors' → 'Illustrious').
|
||||||
|
|
||||||
Field guidance:
|
Field guidance:
|
||||||
- "base_positive": Comma-separated tags that improve output quality for this specific model. Look for recommended positive prompt tags in the HTML.
|
- "base_positive": Comma-separated tags that improve output quality for this specific model. Look for recommended positive prompt tags in the HTML.
|
||||||
- "base_negative": Comma-separated tags to suppress unwanted artifacts. Look for recommended negative prompt tags in the HTML.
|
- "base_negative": Comma-separated tags to suppress unwanted artifacts. Look for recommended negative prompt tags in the HTML.
|
||||||
|
|||||||
@@ -18,8 +18,16 @@ Structure:
|
|||||||
"lora_weight_min": 0.7,
|
"lora_weight_min": 0.7,
|
||||||
"lora_weight_max": 1.0,
|
"lora_weight_max": 1.0,
|
||||||
"lora_triggers": "WILL_BE_REPLACED"
|
"lora_triggers": "WILL_BE_REPLACED"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"associated_resource": "string (one of: General, Looks, Styles, Faces, NSFW — use 'General' for quality/detail enhancers that apply broadly)",
|
||||||
|
"adetailer_targets": ["string (which ADetailer regions this affects: face, hands, body, nsfw)"],
|
||||||
|
"nsfw": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for sexually explicit detail enhancers. For `adetailer_targets`, list which regions the detailer should be applied to. Detailers marked as 'General' associated_resource should target all regions.
|
||||||
|
|
||||||
Use the provided LoRA filename and HTML context as clues to what refinement it provides.
|
Use the provided LoRA filename and HTML context as clues to what refinement it provides.
|
||||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
||||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ Structure:
|
|||||||
"lora_weight_max": 1.0,
|
"lora_weight_max": 1.0,
|
||||||
"lora_triggers": "WILL_BE_REPLACED"
|
"lora_triggers": "WILL_BE_REPLACED"
|
||||||
},
|
},
|
||||||
"tags": ["string", "string"]
|
"tags": {
|
||||||
|
"origin_series": "string (the franchise/series the character look is from, e.g. 'Fire Emblem', 'Dragon Ball'. Use 'Original' if not from any series)",
|
||||||
|
"origin_type": "string (one of: Anime, Video Game, Cartoon, Movie, Comic, Original)",
|
||||||
|
"nsfw": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the look is primarily NSFW.
|
||||||
|
|
||||||
Use the provided LoRA filename and HTML context as clues to what the character look represents.
|
Use the provided LoRA filename and HTML context as clues to what the character look represents.
|
||||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the 'positive'/'negative' fields.
|
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the 'positive'/'negative' fields.
|
||||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ Structure:
|
|||||||
"lora_weight_max": 1.0,
|
"lora_weight_max": 1.0,
|
||||||
"lora_triggers": ""
|
"lora_triggers": ""
|
||||||
},
|
},
|
||||||
"tags": ["string", "string"]
|
"tags": {
|
||||||
|
"outfit_type": "string (one of: Formal, Casual, Swimsuit, Lingerie, Underwear, Nude, Cosplay, Uniform, Fantasy, Armor, Traditional)",
|
||||||
|
"nsfw": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the tags.
|
|
||||||
|
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for lingerie, underwear, nude, or sexually suggestive outfits.
|
||||||
|
|
||||||
|
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the prompt field tags (wardrobe fields). The `tags` object contains semantic metadata — not Danbooru tags.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Structure:
|
|||||||
"action": {
|
"action": {
|
||||||
"action_id": "specific_id | random | null",
|
"action_id": "specific_id | random | null",
|
||||||
"use_lora": true,
|
"use_lora": true,
|
||||||
|
"suppress_wardrobe": null,
|
||||||
"fields": { "base": true, "head": true, "upper_body": true, "lower_body": true, "hands": true, "feet": false, "additional": true }
|
"fields": { "base": true, "head": true, "upper_body": true, "lower_body": true, "hands": true, "feet": false, "additional": true }
|
||||||
},
|
},
|
||||||
"style": { "style_id": "specific_id | random | null", "use_lora": true },
|
"style": { "style_id": "specific_id | random | null", "use_lora": true },
|
||||||
@@ -54,6 +55,8 @@ Guidelines:
|
|||||||
- Set entity IDs to "random" when the user wants variety, null when they want to skip that resource, or a specific ID string when they reference something by name.
|
- Set entity IDs to "random" when the user wants variety, null when they want to skip that resource, or a specific ID string when they reference something by name.
|
||||||
- Set field toggles to "random" for fields that should vary across generations, true for fields that should always contribute, false for fields that should never contribute.
|
- Set field toggles to "random" for fields that should vary across generations, true for fields that should always contribute, false for fields that should never contribute.
|
||||||
- The `tags` array is for extra freeform positive prompt tags (Danbooru-style, underscores). Validate them with the tools.
|
- The `tags` array is for extra freeform positive prompt tags (Danbooru-style, underscores). Validate them with the tools.
|
||||||
|
- `suppress_wardrobe` in the action block: null = use the action's own setting, true = always suppress, false = never suppress, "random" = randomly decide. When suppressed, no wardrobe/clothing prompts are injected.
|
||||||
- The `resolution` object sets image dimensions. Set `random` to true to pick a random aspect ratio each generation. When `random` is false, `width` and `height` are used directly. Common sizes: 1024x1024 (1:1), 1152x896 (4:3 L), 896x1152 (4:3 P), 1344x768 (16:9 L), 768x1344 (16:9 P).
|
- The `resolution` object sets image dimensions. Set `random` to true to pick a random aspect ratio each generation. When `random` is false, `width` and `height` are used directly. Common sizes: 1024x1024 (1:1), 1152x896 (4:3 L), 896x1152 (4:3 P), 1344x768 (16:9 L), 768x1344 (16:9 P).
|
||||||
- Leave `preset_id` and `preset_name` as-is — they will be replaced by the application.
|
- Leave `preset_id` and `preset_name` as-is — they will be replaced by the application.
|
||||||
|
- This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Consider NSFW compatibility when selecting entity combinations.
|
||||||
- Output ONLY valid JSON. No explanations, no markdown fences.
|
- Output ONLY valid JSON. No explanations, no markdown fences.
|
||||||
|
|||||||
48
data/prompts/regenerate_tags_system.txt
Normal file
48
data/prompts/regenerate_tags_system.txt
Normal 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.).
|
||||||
@@ -27,8 +27,14 @@ Structure:
|
|||||||
"lora_weight_max": 1.0,
|
"lora_weight_max": 1.0,
|
||||||
"lora_triggers": "WILL_BE_REPLACED"
|
"lora_triggers": "WILL_BE_REPLACED"
|
||||||
},
|
},
|
||||||
"tags": ["string", "string"]
|
"tags": {
|
||||||
|
"scene_type": "string (one of: Indoor, Outdoor, Fantasy, Urban, Nature, Abstract)",
|
||||||
|
"nsfw": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the scene is inherently NSFW (e.g. love hotel, dungeon).
|
||||||
|
|
||||||
Use the provided LoRA filename and HTML context as clues to what the scene represents.
|
Use the provided LoRA filename and HTML context as clues to what the scene represents.
|
||||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
||||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||||
|
|||||||
@@ -21,8 +21,15 @@ Structure:
|
|||||||
"lora_weight_min": 0.7,
|
"lora_weight_min": 0.7,
|
||||||
"lora_weight_max": 1.0,
|
"lora_weight_max": 1.0,
|
||||||
"lora_triggers": "WILL_BE_REPLACED"
|
"lora_triggers": "WILL_BE_REPLACED"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"style_type": "string (one of: Anime, Realistic, Western, Artistic, Sketch, Watercolor, Digital, Pixel Art)",
|
||||||
|
"nsfw": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the style is primarily used for NSFW content.
|
||||||
|
|
||||||
Use the provided LoRA filename and HTML context as clues to what artist or style it represents.
|
Use the provided LoRA filename and HTML context as clues to what artist or style it represents.
|
||||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight' and 'lora_triggers'.
|
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight' and 'lora_triggers'.
|
||||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||||
|
|||||||
17
models.py
17
models.py
@@ -13,6 +13,9 @@ class Character(db.Model):
|
|||||||
image_path = db.Column(db.String(255), nullable=True)
|
image_path = db.Column(db.String(255), nullable=True)
|
||||||
active_outfit = db.Column(db.String(100), default='default')
|
active_outfit = db.Column(db.String(100), default='default')
|
||||||
|
|
||||||
|
is_favourite = db.Column(db.Boolean, default=False)
|
||||||
|
is_nsfw = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
# NEW: Outfit assignment support (Phase 4)
|
# NEW: Outfit assignment support (Phase 4)
|
||||||
assigned_outfit_ids = db.Column(db.JSON, default=list) # List of outfit_ids from Outfit table
|
assigned_outfit_ids = db.Column(db.JSON, default=list) # List of outfit_ids from Outfit table
|
||||||
default_outfit_id = db.Column(db.String(100), default='default') # 'default' or specific outfit_id
|
default_outfit_id = db.Column(db.String(100), default='default') # 'default' or specific outfit_id
|
||||||
@@ -161,6 +164,8 @@ class Look(db.Model):
|
|||||||
data = db.Column(db.JSON, nullable=False)
|
data = db.Column(db.JSON, nullable=False)
|
||||||
default_fields = db.Column(db.JSON, nullable=True)
|
default_fields = db.Column(db.JSON, nullable=True)
|
||||||
image_path = db.Column(db.String(255), nullable=True)
|
image_path = db.Column(db.String(255), nullable=True)
|
||||||
|
is_favourite = db.Column(db.Boolean, default=False)
|
||||||
|
is_nsfw = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
def get_linked_characters(self):
|
def get_linked_characters(self):
|
||||||
"""Get all characters linked to this look."""
|
"""Get all characters linked to this look."""
|
||||||
@@ -192,6 +197,8 @@ class Outfit(db.Model):
|
|||||||
data = db.Column(db.JSON, nullable=False)
|
data = db.Column(db.JSON, nullable=False)
|
||||||
default_fields = db.Column(db.JSON, nullable=True)
|
default_fields = db.Column(db.JSON, nullable=True)
|
||||||
image_path = db.Column(db.String(255), nullable=True)
|
image_path = db.Column(db.String(255), nullable=True)
|
||||||
|
is_favourite = db.Column(db.Boolean, default=False)
|
||||||
|
is_nsfw = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Outfit {self.outfit_id}>'
|
return f'<Outfit {self.outfit_id}>'
|
||||||
@@ -205,6 +212,8 @@ class Action(db.Model):
|
|||||||
data = db.Column(db.JSON, nullable=False)
|
data = db.Column(db.JSON, nullable=False)
|
||||||
default_fields = db.Column(db.JSON, nullable=True)
|
default_fields = db.Column(db.JSON, nullable=True)
|
||||||
image_path = db.Column(db.String(255), nullable=True)
|
image_path = db.Column(db.String(255), nullable=True)
|
||||||
|
is_favourite = db.Column(db.Boolean, default=False)
|
||||||
|
is_nsfw = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Action {self.action_id}>'
|
return f'<Action {self.action_id}>'
|
||||||
@@ -218,6 +227,8 @@ class Style(db.Model):
|
|||||||
data = db.Column(db.JSON, nullable=False)
|
data = db.Column(db.JSON, nullable=False)
|
||||||
default_fields = db.Column(db.JSON, nullable=True)
|
default_fields = db.Column(db.JSON, nullable=True)
|
||||||
image_path = db.Column(db.String(255), nullable=True)
|
image_path = db.Column(db.String(255), nullable=True)
|
||||||
|
is_favourite = db.Column(db.Boolean, default=False)
|
||||||
|
is_nsfw = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Style {self.style_id}>'
|
return f'<Style {self.style_id}>'
|
||||||
@@ -231,6 +242,8 @@ class Scene(db.Model):
|
|||||||
data = db.Column(db.JSON, nullable=False)
|
data = db.Column(db.JSON, nullable=False)
|
||||||
default_fields = db.Column(db.JSON, nullable=True)
|
default_fields = db.Column(db.JSON, nullable=True)
|
||||||
image_path = db.Column(db.String(255), nullable=True)
|
image_path = db.Column(db.String(255), nullable=True)
|
||||||
|
is_favourite = db.Column(db.Boolean, default=False)
|
||||||
|
is_nsfw = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Scene {self.scene_id}>'
|
return f'<Scene {self.scene_id}>'
|
||||||
@@ -244,6 +257,8 @@ class Detailer(db.Model):
|
|||||||
data = db.Column(db.JSON, nullable=False)
|
data = db.Column(db.JSON, nullable=False)
|
||||||
default_fields = db.Column(db.JSON, nullable=True)
|
default_fields = db.Column(db.JSON, nullable=True)
|
||||||
image_path = db.Column(db.String(255), nullable=True)
|
image_path = db.Column(db.String(255), nullable=True)
|
||||||
|
is_favourite = db.Column(db.Boolean, default=False)
|
||||||
|
is_nsfw = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Detailer {self.detailer_id}>'
|
return f'<Detailer {self.detailer_id}>'
|
||||||
@@ -256,6 +271,8 @@ class Checkpoint(db.Model):
|
|||||||
checkpoint_path = db.Column(db.String(255), nullable=False) # e.g. "Illustrious/model.safetensors"
|
checkpoint_path = db.Column(db.String(255), nullable=False) # e.g. "Illustrious/model.safetensors"
|
||||||
data = db.Column(db.JSON, nullable=True)
|
data = db.Column(db.JSON, nullable=True)
|
||||||
image_path = db.Column(db.String(255), nullable=True)
|
image_path = db.Column(db.String(255), nullable=True)
|
||||||
|
is_favourite = db.Column(db.Boolean, default=False)
|
||||||
|
is_nsfw = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Checkpoint {self.checkpoint_id}>'
|
return f'<Checkpoint {self.checkpoint_id}>'
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ def register_routes(app):
|
|||||||
from routes import strengths
|
from routes import strengths
|
||||||
from routes import transfer
|
from routes import transfer
|
||||||
from routes import api
|
from routes import api
|
||||||
|
from routes import regenerate
|
||||||
|
from routes import search
|
||||||
|
|
||||||
queue_api.register_routes(app)
|
queue_api.register_routes(app)
|
||||||
settings.register_routes(app)
|
settings.register_routes(app)
|
||||||
@@ -37,3 +39,5 @@ def register_routes(app):
|
|||||||
strengths.register_routes(app)
|
strengths.register_routes(app)
|
||||||
transfer.register_routes(app)
|
transfer.register_routes(app)
|
||||||
api.register_routes(app)
|
api.register_routes(app)
|
||||||
|
regenerate.register_routes(app)
|
||||||
|
search.register_routes(app)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import random
|
import random
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
|
|
||||||
from models import db, Character, Action, Outfit, Style, Scene, Detailer, Checkpoint, Settings, Look
|
from models import db, Character, Action, Outfit, Style, Scene, Detailer, Checkpoint, Settings, Look
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||||
from services.sync import sync_actions
|
from services.sync import sync_actions
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
@@ -38,8 +37,17 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/actions')
|
@app.route('/actions')
|
||||||
def actions_index():
|
def actions_index():
|
||||||
actions = Action.query.order_by(Action.name).all()
|
query = Action.query
|
||||||
return render_template('actions/index.html', actions=actions)
|
fav = request.args.get('favourite')
|
||||||
|
nsfw = request.args.get('nsfw', 'all')
|
||||||
|
if fav == 'on':
|
||||||
|
query = query.filter_by(is_favourite=True)
|
||||||
|
if nsfw == 'sfw':
|
||||||
|
query = query.filter_by(is_nsfw=False)
|
||||||
|
elif nsfw == 'nsfw':
|
||||||
|
query = query.filter_by(is_nsfw=True)
|
||||||
|
actions = query.order_by(Action.is_favourite.desc(), Action.name).all()
|
||||||
|
return render_template('actions/index.html', actions=actions, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||||
|
|
||||||
@app.route('/actions/rescan', methods=['POST'])
|
@app.route('/actions/rescan', methods=['POST'])
|
||||||
def rescan_actions():
|
def rescan_actions():
|
||||||
@@ -118,9 +126,15 @@ def register_routes(app):
|
|||||||
else:
|
else:
|
||||||
new_data.setdefault('lora', {}).pop(bound, None)
|
new_data.setdefault('lora', {}).pop(bound, None)
|
||||||
|
|
||||||
# Update Tags (comma separated string to list)
|
# Suppress wardrobe toggle
|
||||||
tags_raw = request.form.get('tags', '')
|
new_data['suppress_wardrobe'] = request.form.get('suppress_wardrobe') == 'on'
|
||||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
|
||||||
|
# Update Tags (structured dict)
|
||||||
|
new_data['tags'] = {
|
||||||
|
'participants': request.form.get('tag_participants', '').strip(),
|
||||||
|
'nsfw': 'tag_nsfw' in request.form,
|
||||||
|
}
|
||||||
|
action.is_nsfw = new_data['tags']['nsfw']
|
||||||
|
|
||||||
action.data = new_data
|
action.data = new_data
|
||||||
flag_modified(action, "data")
|
flag_modified(action, "data")
|
||||||
@@ -201,6 +215,12 @@ def register_routes(app):
|
|||||||
session[f'extra_neg_action_{slug}'] = extra_negative
|
session[f'extra_neg_action_{slug}'] = extra_negative
|
||||||
session.modified = True
|
session.modified = True
|
||||||
|
|
||||||
|
suppress_wardrobe = action_obj.data.get('suppress_wardrobe', False)
|
||||||
|
|
||||||
|
# Strip any wardrobe fields from manual selection when suppressed
|
||||||
|
if suppress_wardrobe:
|
||||||
|
selected_fields = [f for f in selected_fields if not f.startswith('wardrobe::')]
|
||||||
|
|
||||||
# Build combined data for prompt building
|
# Build combined data for prompt building
|
||||||
if character:
|
if character:
|
||||||
# Combine character identity/wardrobe with action details
|
# Combine character identity/wardrobe with action details
|
||||||
@@ -232,16 +252,13 @@ def register_routes(app):
|
|||||||
if 'lora' not in combined_data: combined_data['lora'] = {}
|
if 'lora' not in combined_data: combined_data['lora'] = {}
|
||||||
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}"
|
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}"
|
||||||
|
|
||||||
# Merge tags
|
|
||||||
combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', [])))
|
|
||||||
|
|
||||||
# Use action's defaults if no manual selection
|
# Use action's defaults if no manual selection
|
||||||
if not selected_fields:
|
if not selected_fields:
|
||||||
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
|
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
|
||||||
|
|
||||||
# Auto-include essential character fields if a character is selected
|
# Auto-include essential character fields if a character is selected
|
||||||
if selected_fields:
|
if selected_fields:
|
||||||
_ensure_character_fields(character, selected_fields)
|
_ensure_character_fields(character, selected_fields, include_wardrobe=not suppress_wardrobe)
|
||||||
else:
|
else:
|
||||||
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
|
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
|
||||||
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
|
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
|
||||||
@@ -249,7 +266,8 @@ def register_routes(app):
|
|||||||
for key in ['base', 'head']:
|
for key in ['base', 'head']:
|
||||||
if character.data.get('identity', {}).get(key):
|
if character.data.get('identity', {}).get(key):
|
||||||
selected_fields.append(f'identity::{key}')
|
selected_fields.append(f'identity::{key}')
|
||||||
# Add wardrobe fields
|
# Add wardrobe fields (unless suppressed)
|
||||||
|
if not suppress_wardrobe:
|
||||||
from utils import _WARDROBE_KEYS
|
from utils import _WARDROBE_KEYS
|
||||||
wardrobe = character.get_active_wardrobe()
|
wardrobe = character.get_active_wardrobe()
|
||||||
for key in _WARDROBE_KEYS:
|
for key in _WARDROBE_KEYS:
|
||||||
@@ -281,7 +299,7 @@ def register_routes(app):
|
|||||||
'tags': action_obj.data.get('tags', [])
|
'tags': action_obj.data.get('tags', [])
|
||||||
}
|
}
|
||||||
if not selected_fields:
|
if not selected_fields:
|
||||||
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags']
|
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers']
|
||||||
default_fields = action_obj.default_fields
|
default_fields = action_obj.default_fields
|
||||||
active_outfit = 'default'
|
active_outfit = 'default'
|
||||||
|
|
||||||
@@ -322,7 +340,8 @@ def register_routes(app):
|
|||||||
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
|
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
|
||||||
extra_parts.append(val)
|
extra_parts.append(val)
|
||||||
|
|
||||||
# Wardrobe (active outfit)
|
# Wardrobe (active outfit) — skip if suppressed
|
||||||
|
if not suppress_wardrobe:
|
||||||
from utils import _WARDROBE_KEYS
|
from utils import _WARDROBE_KEYS
|
||||||
wardrobe = extra_char.get_active_wardrobe()
|
wardrobe = extra_char.get_active_wardrobe()
|
||||||
for key in _WARDROBE_KEYS:
|
for key in _WARDROBE_KEYS:
|
||||||
@@ -391,21 +410,27 @@ def register_routes(app):
|
|||||||
actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/')
|
actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/')
|
||||||
_lora_subfolder = os.path.basename(actions_lora_dir)
|
_lora_subfolder = os.path.basename(actions_lora_dir)
|
||||||
if not os.path.exists(actions_lora_dir):
|
if not os.path.exists(actions_lora_dir):
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'error': 'Actions LoRA directory not found.'}, 400
|
||||||
flash('Actions LoRA directory not found.', 'error')
|
flash('Actions LoRA directory not found.', 'error')
|
||||||
return redirect(url_for('actions_index'))
|
return redirect(url_for('actions_index'))
|
||||||
|
|
||||||
overwrite = request.form.get('overwrite') == 'true'
|
overwrite = request.form.get('overwrite') == 'true'
|
||||||
created_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
overwritten_count = 0
|
|
||||||
|
|
||||||
system_prompt = load_prompt('action_system.txt')
|
system_prompt = load_prompt('action_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'error': 'Action system prompt file not found.'}, 500
|
||||||
flash('Action system prompt file not found.', 'error')
|
flash('Action system prompt file not found.', 'error')
|
||||||
return redirect(url_for('actions_index'))
|
return redirect(url_for('actions_index'))
|
||||||
|
|
||||||
for filename in os.listdir(actions_lora_dir):
|
job_ids = []
|
||||||
if filename.endswith('.safetensors'):
|
skipped = 0
|
||||||
|
|
||||||
|
for filename in sorted(os.listdir(actions_lora_dir)):
|
||||||
|
if not filename.endswith('.safetensors'):
|
||||||
|
continue
|
||||||
|
|
||||||
name_base = filename.rsplit('.', 1)[0]
|
name_base = filename.rsplit('.', 1)[0]
|
||||||
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
||||||
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||||
@@ -415,48 +440,46 @@ def register_routes(app):
|
|||||||
|
|
||||||
is_existing = os.path.exists(json_path)
|
is_existing = os.path.exists(json_path)
|
||||||
if is_existing and not overwrite:
|
if is_existing and not overwrite:
|
||||||
skipped_count += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
html_filename = f"{name_base}.html"
|
# Read HTML companion file if it exists
|
||||||
html_path = os.path.join(actions_lora_dir, html_filename)
|
html_path = os.path.join(actions_lora_dir, f"{name_base}.html")
|
||||||
html_content = ""
|
html_content = ""
|
||||||
if os.path.exists(html_path):
|
if os.path.exists(html_path):
|
||||||
try:
|
try:
|
||||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||||
html_raw = hf.read()
|
html_raw = hf.read()
|
||||||
# Strip HTML tags but keep text content for LLM context
|
|
||||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||||
html_content = ' '.join(clean_html.split())
|
html_content = ' '.join(clean_html.split())
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f"Error reading HTML {html_filename}: {e}")
|
pass
|
||||||
|
|
||||||
try:
|
def make_task(fn, aid, aname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||||
print(f"Asking LLM to describe action: {action_name}")
|
def task_fn(job):
|
||||||
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{filename}'"
|
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{fn}'"
|
||||||
if html_content:
|
if html_ctx:
|
||||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
|
||||||
|
|
||||||
llm_response = call_llm(prompt, system_prompt)
|
llm_response = call_llm(prompt, sys_prompt)
|
||||||
|
|
||||||
# Clean response
|
|
||||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||||
action_data = json.loads(clean_json)
|
action_data = json.loads(clean_json)
|
||||||
|
|
||||||
# Enforce system values while preserving LLM-extracted metadata
|
# Enforce system values while preserving LLM-extracted metadata
|
||||||
action_data['action_id'] = action_id
|
action_data['action_id'] = aid
|
||||||
action_data['action_name'] = action_name
|
action_data['action_name'] = aname
|
||||||
|
|
||||||
# Update lora dict safely
|
# Update lora dict safely
|
||||||
if 'lora' not in action_data: action_data['lora'] = {}
|
if 'lora' not in action_data:
|
||||||
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
action_data['lora'] = {}
|
||||||
|
action_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||||
|
|
||||||
# Fallbacks if LLM failed to extract metadata
|
# Fallbacks if LLM failed to extract metadata
|
||||||
if not action_data['lora'].get('lora_triggers'):
|
if not action_data['lora'].get('lora_triggers'):
|
||||||
action_data['lora']['lora_triggers'] = name_base
|
action_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||||
if action_data['lora'].get('lora_weight') is None:
|
if action_data['lora'].get('lora_weight') is None:
|
||||||
action_data['lora']['lora_weight'] = 1.0
|
action_data['lora']['lora_weight'] = 1.0
|
||||||
if action_data['lora'].get('lora_weight_min') is None:
|
if action_data['lora'].get('lora_weight_min') is None:
|
||||||
@@ -464,39 +487,45 @@ def register_routes(app):
|
|||||||
if action_data['lora'].get('lora_weight_max') is None:
|
if action_data['lora'].get('lora_weight_max') is None:
|
||||||
action_data['lora']['lora_weight_max'] = 1.0
|
action_data['lora']['lora_weight_max'] = 1.0
|
||||||
|
|
||||||
with open(json_path, 'w') as f:
|
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||||
|
with open(jp, 'w') as f:
|
||||||
json.dump(action_data, f, indent=2)
|
json.dump(action_data, f, indent=2)
|
||||||
|
|
||||||
if is_existing:
|
job['result'] = {'name': aname, 'action': 'overwritten' if is_exist else 'created'}
|
||||||
overwritten_count += 1
|
return task_fn
|
||||||
else:
|
|
||||||
created_count += 1
|
|
||||||
|
|
||||||
# Small delay to avoid API rate limits if many files
|
job = _enqueue_task(
|
||||||
time.sleep(0.5)
|
f"Create action: {action_name}",
|
||||||
|
make_task(filename, action_id, action_name, json_path,
|
||||||
|
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||||
|
)
|
||||||
|
job_ids.append(job['id'])
|
||||||
|
|
||||||
except Exception as e:
|
# Enqueue a sync task to run after all creates
|
||||||
print(f"Error creating action for {filename}: {e}")
|
if job_ids:
|
||||||
|
def sync_task(job):
|
||||||
if created_count > 0 or overwritten_count > 0:
|
|
||||||
sync_actions()
|
sync_actions()
|
||||||
msg = f'Successfully processed actions: {created_count} created, {overwritten_count} overwritten.'
|
job['result'] = {'synced': True}
|
||||||
if skipped_count > 0:
|
_enqueue_task("Sync actions DB", sync_task)
|
||||||
msg += f' (Skipped {skipped_count} existing)'
|
|
||||||
flash(msg)
|
|
||||||
else:
|
|
||||||
flash(f'No actions created or overwritten. {skipped_count} existing actions found.')
|
|
||||||
|
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||||
|
|
||||||
|
flash(f'Queued {len(job_ids)} action creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||||
return redirect(url_for('actions_index'))
|
return redirect(url_for('actions_index'))
|
||||||
|
|
||||||
@app.route('/action/create', methods=['GET', 'POST'])
|
@app.route('/action/create', methods=['GET', 'POST'])
|
||||||
def create_action():
|
def create_action():
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name')
|
name = request.form.get('name')
|
||||||
slug = request.form.get('filename', '').strip()
|
slug = request.form.get('filename', '').strip()
|
||||||
prompt = request.form.get('prompt', '')
|
prompt = request.form.get('prompt', '')
|
||||||
use_llm = request.form.get('use_llm') == 'on'
|
use_llm = request.form.get('use_llm') == 'on'
|
||||||
|
|
||||||
|
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
|
||||||
|
|
||||||
if not slug:
|
if not slug:
|
||||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||||
|
|
||||||
@@ -513,12 +542,12 @@ def register_routes(app):
|
|||||||
if use_llm:
|
if use_llm:
|
||||||
if not prompt:
|
if not prompt:
|
||||||
flash("Description is required when AI generation is enabled.")
|
flash("Description is required when AI generation is enabled.")
|
||||||
return redirect(request.url)
|
return render_template('actions/create.html', form_data=form_data)
|
||||||
|
|
||||||
system_prompt = load_prompt('action_system.txt')
|
system_prompt = load_prompt('action_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
flash("Action system prompt file not found.")
|
flash("Action system prompt file not found.")
|
||||||
return redirect(request.url)
|
return render_template('actions/create.html', form_data=form_data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt)
|
llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt)
|
||||||
@@ -529,7 +558,7 @@ def register_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LLM error: {e}")
|
print(f"LLM error: {e}")
|
||||||
flash(f"Failed to generate action profile: {e}")
|
flash(f"Failed to generate action profile: {e}")
|
||||||
return redirect(request.url)
|
return render_template('actions/create.html', form_data=form_data)
|
||||||
else:
|
else:
|
||||||
action_data = {
|
action_data = {
|
||||||
"action_id": safe_slug,
|
"action_id": safe_slug,
|
||||||
@@ -538,6 +567,7 @@ def register_routes(app):
|
|||||||
"base": "", "head": "", "upper_body": "", "lower_body": "",
|
"base": "", "head": "", "upper_body": "", "lower_body": "",
|
||||||
"hands": "", "feet": "", "additional": ""
|
"hands": "", "feet": "", "additional": ""
|
||||||
},
|
},
|
||||||
|
"suppress_wardrobe": False,
|
||||||
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
|
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
|
||||||
"tags": []
|
"tags": []
|
||||||
}
|
}
|
||||||
@@ -559,9 +589,9 @@ def register_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
print(f"Save error: {e}")
|
||||||
flash(f"Failed to create action: {e}")
|
flash(f"Failed to create action: {e}")
|
||||||
return redirect(request.url)
|
return render_template('actions/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('actions/create.html')
|
return render_template('actions/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/action/<path:slug>/clone', methods=['POST'])
|
@app.route('/action/<path:slug>/clone', methods=['POST'])
|
||||||
def clone_action(slug):
|
def clone_action(slug):
|
||||||
@@ -619,3 +649,12 @@ def register_routes(app):
|
|||||||
with open(file_path, 'w') as f:
|
with open(file_path, 'w') as f:
|
||||||
json.dump(new_data, f, indent=2)
|
json.dump(new_data, f, indent=2)
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
|
|
||||||
|
@app.route('/action/<path:slug>/favourite', methods=['POST'])
|
||||||
|
def toggle_action_favourite(slug):
|
||||||
|
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||||
|
action.is_favourite = not action.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': action.is_favourite}
|
||||||
|
return redirect(url_for('action_detail', slug=slug))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from werkzeug.utils import secure_filename
|
|||||||
from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
|
from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize
|
||||||
from services.llm import call_llm, load_prompt
|
from services.llm import call_character_mcp_tool, call_llm, load_prompt
|
||||||
from services.prompts import build_prompt
|
from services.prompts import build_prompt
|
||||||
from services.sync import sync_characters
|
from services.sync import sync_characters
|
||||||
from services.workflow import _get_default_checkpoint, _prepare_workflow
|
from services.workflow import _get_default_checkpoint, _prepare_workflow
|
||||||
@@ -23,8 +23,17 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
characters = Character.query.order_by(Character.name).all()
|
query = Character.query
|
||||||
return render_template('index.html', characters=characters)
|
fav = request.args.get('favourite')
|
||||||
|
nsfw = request.args.get('nsfw', 'all')
|
||||||
|
if fav == 'on':
|
||||||
|
query = query.filter_by(is_favourite=True)
|
||||||
|
if nsfw == 'sfw':
|
||||||
|
query = query.filter_by(is_nsfw=False)
|
||||||
|
elif nsfw == 'nsfw':
|
||||||
|
query = query.filter_by(is_nsfw=True)
|
||||||
|
characters = query.order_by(Character.is_favourite.desc(), Character.name).all()
|
||||||
|
return render_template('index.html', characters=characters, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||||
|
|
||||||
@app.route('/rescan', methods=['POST'])
|
@app.route('/rescan', methods=['POST'])
|
||||||
def rescan():
|
def rescan():
|
||||||
@@ -219,6 +228,7 @@ def register_routes(app):
|
|||||||
name = request.form.get('name')
|
name = request.form.get('name')
|
||||||
slug = request.form.get('filename', '').strip()
|
slug = request.form.get('filename', '').strip()
|
||||||
prompt = request.form.get('prompt', '')
|
prompt = request.form.get('prompt', '')
|
||||||
|
wiki_url = request.form.get('wiki_url', '').strip()
|
||||||
use_llm = request.form.get('use_llm') == 'on'
|
use_llm = request.form.get('use_llm') == 'on'
|
||||||
outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none'
|
outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none'
|
||||||
existing_outfit_id = request.form.get('existing_outfit_id')
|
existing_outfit_id = request.form.get('existing_outfit_id')
|
||||||
@@ -228,6 +238,7 @@ def register_routes(app):
|
|||||||
'name': name,
|
'name': name,
|
||||||
'filename': slug,
|
'filename': slug,
|
||||||
'prompt': prompt,
|
'prompt': prompt,
|
||||||
|
'wiki_url': wiki_url,
|
||||||
'use_llm': use_llm,
|
'use_llm': use_llm,
|
||||||
'outfit_mode': outfit_mode,
|
'outfit_mode': outfit_mode,
|
||||||
'existing_outfit_id': existing_outfit_id
|
'existing_outfit_id': existing_outfit_id
|
||||||
@@ -261,6 +272,20 @@ def register_routes(app):
|
|||||||
flash(error_msg)
|
flash(error_msg)
|
||||||
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
||||||
|
|
||||||
|
# Fetch reference data from wiki URL if provided
|
||||||
|
wiki_reference = ''
|
||||||
|
if wiki_url:
|
||||||
|
logger.info(f"Fetching character data from URL: {wiki_url}")
|
||||||
|
wiki_data = call_character_mcp_tool('get_character_from_url', {
|
||||||
|
'url': wiki_url,
|
||||||
|
'name': name,
|
||||||
|
})
|
||||||
|
if wiki_data:
|
||||||
|
wiki_reference = f"\n\nReference data from wiki:\n{wiki_data}\n\nUse this reference to accurately describe the character's appearance, outfit, and features."
|
||||||
|
logger.info(f"Got wiki reference data ({len(wiki_data)} chars)")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to fetch wiki data from {wiki_url}")
|
||||||
|
|
||||||
# Step 1: Generate or select outfit first
|
# Step 1: Generate or select outfit first
|
||||||
default_outfit_id = 'default'
|
default_outfit_id = 'default'
|
||||||
generated_outfit = None
|
generated_outfit = None
|
||||||
@@ -271,7 +296,7 @@ def register_routes(app):
|
|||||||
outfit_name = f"{name} - default"
|
outfit_name = f"{name} - default"
|
||||||
|
|
||||||
outfit_prompt = f"""Generate an outfit for character "{name}".
|
outfit_prompt = f"""Generate an outfit for character "{name}".
|
||||||
The character is described as: {prompt}
|
The character is described as: {prompt}{wiki_reference}
|
||||||
|
|
||||||
Create an outfit JSON with wardrobe fields appropriate for this character."""
|
Create an outfit JSON with wardrobe fields appropriate for this character."""
|
||||||
|
|
||||||
@@ -344,7 +369,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
|
|||||||
|
|
||||||
# Step 2: Generate character (without wardrobe section)
|
# Step 2: Generate character (without wardrobe section)
|
||||||
char_prompt = f"""Generate a character named "{name}".
|
char_prompt = f"""Generate a character named "{name}".
|
||||||
Description: {prompt}
|
Description: {prompt}{wiki_reference}
|
||||||
|
|
||||||
Default Outfit: {default_outfit_id}
|
Default Outfit: {default_outfit_id}
|
||||||
|
|
||||||
@@ -516,9 +541,13 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
if form_key in request.form:
|
if form_key in request.form:
|
||||||
new_data['wardrobe'][key] = request.form.get(form_key)
|
new_data['wardrobe'][key] = request.form.get(form_key)
|
||||||
|
|
||||||
# Update Tags (comma separated string to list)
|
# Update structured tags
|
||||||
tags_raw = request.form.get('tags', '')
|
new_data['tags'] = {
|
||||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
'origin_series': request.form.get('tag_origin_series', '').strip(),
|
||||||
|
'origin_type': request.form.get('tag_origin_type', '').strip(),
|
||||||
|
'nsfw': 'tag_nsfw' in request.form,
|
||||||
|
}
|
||||||
|
character.is_nsfw = new_data['tags']['nsfw']
|
||||||
|
|
||||||
character.data = new_data
|
character.data = new_data
|
||||||
flag_modified(character, "data")
|
flag_modified(character, "data")
|
||||||
@@ -867,3 +896,12 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Default prompt selection saved for this character!')
|
flash('Default prompt selection saved for this character!')
|
||||||
return redirect(url_for('detail', slug=slug))
|
return redirect(url_for('detail', slug=slug))
|
||||||
|
|
||||||
|
@app.route('/character/<path:slug>/favourite', methods=['POST'])
|
||||||
|
def toggle_character_favourite(slug):
|
||||||
|
character = Character.query.filter_by(slug=slug).first_or_404()
|
||||||
|
character.is_favourite = not character.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': character.is_favourite}
|
||||||
|
return redirect(url_for('detail', slug=slug))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
|
|
||||||
from models import db, Checkpoint, Character, Settings
|
from models import db, Checkpoint, Character, Settings
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts
|
from services.workflow import _prepare_workflow, _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||||
from services.sync import sync_checkpoints, _default_checkpoint_data
|
from services.sync import sync_checkpoints, _default_checkpoint_data
|
||||||
from services.file_io import get_available_checkpoints
|
from services.file_io import get_available_checkpoints
|
||||||
@@ -57,8 +56,17 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/checkpoints')
|
@app.route('/checkpoints')
|
||||||
def checkpoints_index():
|
def checkpoints_index():
|
||||||
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
|
query = Checkpoint.query
|
||||||
return render_template('checkpoints/index.html', checkpoints=checkpoints)
|
fav = request.args.get('favourite')
|
||||||
|
nsfw = request.args.get('nsfw', 'all')
|
||||||
|
if fav == 'on':
|
||||||
|
query = query.filter_by(is_favourite=True)
|
||||||
|
if nsfw == 'sfw':
|
||||||
|
query = query.filter_by(is_nsfw=False)
|
||||||
|
elif nsfw == 'nsfw':
|
||||||
|
query = query.filter_by(is_nsfw=True)
|
||||||
|
checkpoints = query.order_by(Checkpoint.is_favourite.desc(), Checkpoint.name).all()
|
||||||
|
return render_template('checkpoints/index.html', checkpoints=checkpoints, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||||
|
|
||||||
@app.route('/checkpoints/rescan', methods=['POST'])
|
@app.route('/checkpoints/rescan', methods=['POST'])
|
||||||
def rescan_checkpoints():
|
def rescan_checkpoints():
|
||||||
@@ -189,9 +197,9 @@ def register_routes(app):
|
|||||||
os.makedirs(checkpoints_dir, exist_ok=True)
|
os.makedirs(checkpoints_dir, exist_ok=True)
|
||||||
|
|
||||||
overwrite = request.form.get('overwrite') == 'true'
|
overwrite = request.form.get('overwrite') == 'true'
|
||||||
created_count = 0
|
skipped = 0
|
||||||
skipped_count = 0
|
written_directly = 0
|
||||||
overwritten_count = 0
|
job_ids = []
|
||||||
|
|
||||||
system_prompt = load_prompt('checkpoint_system.txt')
|
system_prompt = load_prompt('checkpoint_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
@@ -219,7 +227,7 @@ def register_routes(app):
|
|||||||
|
|
||||||
is_existing = os.path.exists(json_path)
|
is_existing = os.path.exists(json_path)
|
||||||
if is_existing and not overwrite:
|
if is_existing and not overwrite:
|
||||||
skipped_count += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Look for a matching HTML file alongside the model file
|
# Look for a matching HTML file alongside the model file
|
||||||
@@ -235,52 +243,72 @@ def register_routes(app):
|
|||||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||||
html_content = ' '.join(clean_html.split())
|
html_content = ' '.join(clean_html.split())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading HTML for {filename}: {e}")
|
logger.error("Error reading HTML for %s: %s", filename, e)
|
||||||
|
|
||||||
defaults = _default_checkpoint_data(checkpoint_path, filename)
|
defaults = _default_checkpoint_data(checkpoint_path, filename)
|
||||||
|
|
||||||
if html_content:
|
if html_content:
|
||||||
try:
|
# Has HTML companion — enqueue LLM task
|
||||||
print(f"Asking LLM to describe checkpoint: {filename}")
|
def make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing):
|
||||||
|
def task_fn(job):
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
|
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
|
||||||
f"(checkpoint_path: '{checkpoint_path}').\n\n"
|
f"(checkpoint_path: '{checkpoint_path}').\n\n"
|
||||||
f"Here is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
f"Here is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
llm_response = call_llm(prompt, system_prompt)
|
llm_response = call_llm(prompt, system_prompt)
|
||||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||||
ckpt_data = json.loads(clean_json)
|
ckpt_data = json.loads(clean_json)
|
||||||
# Enforce fixed fields
|
|
||||||
ckpt_data['checkpoint_path'] = checkpoint_path
|
ckpt_data['checkpoint_path'] = checkpoint_path
|
||||||
ckpt_data['checkpoint_name'] = filename
|
ckpt_data['checkpoint_name'] = filename
|
||||||
# Fill missing fields with defaults
|
|
||||||
for key, val in defaults.items():
|
for key, val in defaults.items():
|
||||||
if key not in ckpt_data or ckpt_data[key] is None:
|
if key not in ckpt_data or ckpt_data[key] is None:
|
||||||
ckpt_data[key] = val
|
ckpt_data[key] = val
|
||||||
time.sleep(0.5)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LLM error for {filename}: {e}. Using defaults.")
|
logger.error("LLM error for %s: %s. Using defaults.", filename, e)
|
||||||
ckpt_data = defaults
|
|
||||||
else:
|
|
||||||
ckpt_data = defaults
|
ckpt_data = defaults
|
||||||
|
|
||||||
try:
|
|
||||||
with open(json_path, 'w') as f:
|
with open(json_path, 'w') as f:
|
||||||
json.dump(ckpt_data, f, indent=2)
|
json.dump(ckpt_data, f, indent=2)
|
||||||
if is_existing:
|
|
||||||
overwritten_count += 1
|
job['result'] = {'name': filename, 'action': 'overwritten' if is_existing else 'created'}
|
||||||
|
return task_fn
|
||||||
|
|
||||||
|
job = _enqueue_task(f"Create checkpoint: {filename}", make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing))
|
||||||
|
job_ids.append(job['id'])
|
||||||
else:
|
else:
|
||||||
created_count += 1
|
# No HTML — write defaults directly (no LLM needed)
|
||||||
|
try:
|
||||||
|
with open(json_path, 'w') as f:
|
||||||
|
json.dump(defaults, f, indent=2)
|
||||||
|
written_directly += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving JSON for {filename}: {e}")
|
logger.error("Error saving JSON for %s: %s", filename, e)
|
||||||
|
|
||||||
if created_count > 0 or overwritten_count > 0:
|
needs_sync = len(job_ids) > 0 or written_directly > 0
|
||||||
|
|
||||||
|
if needs_sync:
|
||||||
|
if job_ids:
|
||||||
|
# Sync after all LLM tasks complete
|
||||||
|
def sync_task(job):
|
||||||
sync_checkpoints()
|
sync_checkpoints()
|
||||||
msg = f'Successfully processed checkpoints: {created_count} created, {overwritten_count} overwritten.'
|
job['result'] = {'synced': True}
|
||||||
if skipped_count > 0:
|
_enqueue_task("Sync checkpoints DB", sync_task)
|
||||||
msg += f' (Skipped {skipped_count} existing)'
|
|
||||||
flash(msg)
|
|
||||||
else:
|
else:
|
||||||
flash(f'No checkpoints created or overwritten. {skipped_count} existing entries found.')
|
# No LLM tasks — sync immediately
|
||||||
|
sync_checkpoints()
|
||||||
|
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'queued': len(job_ids), 'written_directly': written_directly, 'skipped': skipped}
|
||||||
|
flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).')
|
||||||
return redirect(url_for('checkpoints_index'))
|
return redirect(url_for('checkpoints_index'))
|
||||||
|
|
||||||
|
@app.route('/checkpoint/<path:slug>/favourite', methods=['POST'])
|
||||||
|
def toggle_checkpoint_favourite(slug):
|
||||||
|
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
|
||||||
|
ckpt.is_favourite = not ckpt.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': ckpt.is_favourite}
|
||||||
|
return redirect(url_for('checkpoint_detail', slug=slug))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
|
|
||||||
from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look
|
from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||||
from services.sync import sync_detailers
|
from services.sync import sync_detailers
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
@@ -27,11 +26,8 @@ def register_routes(app):
|
|||||||
combined_data = character.data.copy()
|
combined_data = character.data.copy()
|
||||||
combined_data['character_id'] = character.character_id
|
combined_data['character_id'] = character.character_id
|
||||||
|
|
||||||
# Merge detailer prompt into character's tags
|
# Capture detailer prompt for injection into main prompt later
|
||||||
detailer_prompt = detailer_obj.data.get('prompt', '')
|
detailer_prompt = detailer_obj.data.get('prompt', '')
|
||||||
if detailer_prompt:
|
|
||||||
if 'tags' not in combined_data: combined_data['tags'] = []
|
|
||||||
combined_data['tags'].append(detailer_prompt)
|
|
||||||
|
|
||||||
# Merge detailer lora triggers if present
|
# Merge detailer lora triggers if present
|
||||||
detailer_lora = detailer_obj.data.get('lora', {})
|
detailer_lora = detailer_obj.data.get('lora', {})
|
||||||
@@ -53,21 +49,19 @@ def register_routes(app):
|
|||||||
for key in _WARDROBE_KEYS:
|
for key in _WARDROBE_KEYS:
|
||||||
if wardrobe.get(key):
|
if wardrobe.get(key):
|
||||||
selected_fields.append(f'wardrobe::{key}')
|
selected_fields.append(f'wardrobe::{key}')
|
||||||
selected_fields.extend(['special::tags', 'lora::lora_triggers'])
|
selected_fields.extend(['lora::lora_triggers'])
|
||||||
|
|
||||||
default_fields = detailer_obj.default_fields
|
default_fields = detailer_obj.default_fields
|
||||||
active_outfit = character.active_outfit
|
active_outfit = character.active_outfit
|
||||||
else:
|
else:
|
||||||
# Detailer only - no character
|
# Detailer only - no character
|
||||||
detailer_prompt = detailer_obj.data.get('prompt', '')
|
detailer_prompt = detailer_obj.data.get('prompt', '')
|
||||||
detailer_tags = [detailer_prompt] if detailer_prompt else []
|
|
||||||
combined_data = {
|
combined_data = {
|
||||||
'character_id': detailer_obj.detailer_id,
|
'character_id': detailer_obj.detailer_id,
|
||||||
'tags': detailer_tags,
|
|
||||||
'lora': detailer_obj.data.get('lora', {}),
|
'lora': detailer_obj.data.get('lora', {}),
|
||||||
}
|
}
|
||||||
if not selected_fields:
|
if not selected_fields:
|
||||||
selected_fields = ['special::tags', 'lora::lora_triggers']
|
selected_fields = ['lora::lora_triggers']
|
||||||
default_fields = detailer_obj.default_fields
|
default_fields = detailer_obj.default_fields
|
||||||
active_outfit = 'default'
|
active_outfit = 'default'
|
||||||
|
|
||||||
@@ -76,6 +70,11 @@ def register_routes(app):
|
|||||||
|
|
||||||
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
|
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
|
||||||
|
|
||||||
|
# Inject detailer prompt directly into main prompt
|
||||||
|
if detailer_prompt:
|
||||||
|
prompt_str = detailer_prompt if isinstance(detailer_prompt, str) else ', '.join(detailer_prompt)
|
||||||
|
prompts['main'] = f"{prompts['main']}, {prompt_str}" if prompts['main'] else prompt_str
|
||||||
|
|
||||||
_append_background(prompts, character)
|
_append_background(prompts, character)
|
||||||
|
|
||||||
if extra_positive:
|
if extra_positive:
|
||||||
@@ -87,8 +86,17 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/detailers')
|
@app.route('/detailers')
|
||||||
def detailers_index():
|
def detailers_index():
|
||||||
detailers = Detailer.query.order_by(Detailer.name).all()
|
query = Detailer.query
|
||||||
return render_template('detailers/index.html', detailers=detailers)
|
fav = request.args.get('favourite')
|
||||||
|
nsfw = request.args.get('nsfw', 'all')
|
||||||
|
if fav == 'on':
|
||||||
|
query = query.filter_by(is_favourite=True)
|
||||||
|
if nsfw == 'sfw':
|
||||||
|
query = query.filter_by(is_nsfw=False)
|
||||||
|
elif nsfw == 'nsfw':
|
||||||
|
query = query.filter_by(is_nsfw=True)
|
||||||
|
detailers = query.order_by(Detailer.is_favourite.desc(), Detailer.name).all()
|
||||||
|
return render_template('detailers/index.html', detailers=detailers, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||||
|
|
||||||
@app.route('/detailers/rescan', methods=['POST'])
|
@app.route('/detailers/rescan', methods=['POST'])
|
||||||
def rescan_detailers():
|
def rescan_detailers():
|
||||||
@@ -162,9 +170,13 @@ def register_routes(app):
|
|||||||
else:
|
else:
|
||||||
new_data.setdefault('lora', {}).pop(bound, None)
|
new_data.setdefault('lora', {}).pop(bound, None)
|
||||||
|
|
||||||
# Update Tags (comma separated string to list)
|
# Update structured tags
|
||||||
tags_raw = request.form.get('tags', '')
|
new_data['tags'] = {
|
||||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
'associated_resource': request.form.get('tag_associated_resource', '').strip(),
|
||||||
|
'adetailer_targets': request.form.getlist('tag_adetailer_targets'),
|
||||||
|
'nsfw': 'tag_nsfw' in request.form,
|
||||||
|
}
|
||||||
|
detailer.is_nsfw = new_data['tags']['nsfw']
|
||||||
|
|
||||||
detailer.data = new_data
|
detailer.data = new_data
|
||||||
flag_modified(detailer, "data")
|
flag_modified(detailer, "data")
|
||||||
@@ -318,15 +330,16 @@ def register_routes(app):
|
|||||||
return redirect(url_for('detailers_index'))
|
return redirect(url_for('detailers_index'))
|
||||||
|
|
||||||
overwrite = request.form.get('overwrite') == 'true'
|
overwrite = request.form.get('overwrite') == 'true'
|
||||||
created_count = 0
|
skipped = 0
|
||||||
skipped_count = 0
|
job_ids = []
|
||||||
overwritten_count = 0
|
|
||||||
|
|
||||||
system_prompt = load_prompt('detailer_system.txt')
|
system_prompt = load_prompt('detailer_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
flash('Detailer system prompt file not found.', 'error')
|
flash('Detailer system prompt file not found.', 'error')
|
||||||
return redirect(url_for('detailers_index'))
|
return redirect(url_for('detailers_index'))
|
||||||
|
|
||||||
|
detailers_dir = app.config['DETAILERS_DIR']
|
||||||
|
|
||||||
for filename in os.listdir(detailers_lora_dir):
|
for filename in os.listdir(detailers_lora_dir):
|
||||||
if filename.endswith('.safetensors'):
|
if filename.endswith('.safetensors'):
|
||||||
name_base = filename.rsplit('.', 1)[0]
|
name_base = filename.rsplit('.', 1)[0]
|
||||||
@@ -334,11 +347,11 @@ def register_routes(app):
|
|||||||
detailer_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
detailer_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||||
|
|
||||||
json_filename = f"{detailer_id}.json"
|
json_filename = f"{detailer_id}.json"
|
||||||
json_path = os.path.join(app.config['DETAILERS_DIR'], json_filename)
|
json_path = os.path.join(detailers_dir, json_filename)
|
||||||
|
|
||||||
is_existing = os.path.exists(json_path)
|
is_existing = os.path.exists(json_path)
|
||||||
if is_existing and not overwrite:
|
if is_existing and not overwrite:
|
||||||
skipped_count += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
html_filename = f"{name_base}.html"
|
html_filename = f"{name_base}.html"
|
||||||
@@ -354,10 +367,10 @@ def register_routes(app):
|
|||||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||||
html_content = ' '.join(clean_html.split())
|
html_content = ' '.join(clean_html.split())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading HTML {html_filename}: {e}")
|
logger.error("Error reading HTML %s: %s", html_filename, e)
|
||||||
|
|
||||||
try:
|
def make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||||
print(f"Asking LLM to describe detailer: {detailer_name}")
|
def task_fn(job):
|
||||||
prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'"
|
prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'"
|
||||||
if html_content:
|
if html_content:
|
||||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||||
@@ -384,33 +397,33 @@ def register_routes(app):
|
|||||||
with open(json_path, 'w') as f:
|
with open(json_path, 'w') as f:
|
||||||
json.dump(detailer_data, f, indent=2)
|
json.dump(detailer_data, f, indent=2)
|
||||||
|
|
||||||
if is_existing:
|
job['result'] = {'name': detailer_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||||
overwritten_count += 1
|
return task_fn
|
||||||
else:
|
|
||||||
created_count += 1
|
|
||||||
|
|
||||||
# Small delay to avoid API rate limits if many files
|
job = _enqueue_task(f"Create detailer: {detailer_name}", make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||||
time.sleep(0.5)
|
job_ids.append(job['id'])
|
||||||
except Exception as e:
|
|
||||||
print(f"Error creating detailer for {filename}: {e}")
|
|
||||||
|
|
||||||
if created_count > 0 or overwritten_count > 0:
|
if job_ids:
|
||||||
|
def sync_task(job):
|
||||||
sync_detailers()
|
sync_detailers()
|
||||||
msg = f'Successfully processed detailers: {created_count} created, {overwritten_count} overwritten.'
|
job['result'] = {'synced': True}
|
||||||
if skipped_count > 0:
|
_enqueue_task("Sync detailers DB", sync_task)
|
||||||
msg += f' (Skipped {skipped_count} existing)'
|
|
||||||
flash(msg)
|
|
||||||
else:
|
|
||||||
flash(f'No new detailers created or overwritten. {skipped_count} existing detailers found.')
|
|
||||||
|
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||||
|
flash(f'Queued {len(job_ids)} detailer tasks ({skipped} skipped).')
|
||||||
return redirect(url_for('detailers_index'))
|
return redirect(url_for('detailers_index'))
|
||||||
|
|
||||||
@app.route('/detailer/create', methods=['GET', 'POST'])
|
@app.route('/detailer/create', methods=['GET', 'POST'])
|
||||||
def create_detailer():
|
def create_detailer():
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name')
|
name = request.form.get('name')
|
||||||
slug = request.form.get('filename', '').strip()
|
slug = request.form.get('filename', '').strip()
|
||||||
|
|
||||||
|
form_data = {'name': name, 'filename': slug}
|
||||||
|
|
||||||
if not slug:
|
if not slug:
|
||||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||||
|
|
||||||
@@ -452,6 +465,15 @@ def register_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
print(f"Save error: {e}")
|
||||||
flash(f"Failed to create detailer: {e}")
|
flash(f"Failed to create detailer: {e}")
|
||||||
return redirect(request.url)
|
return render_template('detailers/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('detailers/create.html')
|
return render_template('detailers/create.html', form_data=form_data)
|
||||||
|
|
||||||
|
@app.route('/detailer/<path:slug>/favourite', methods=['POST'])
|
||||||
|
def toggle_detailer_favourite(slug):
|
||||||
|
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
|
||||||
|
detailer.is_favourite = not detailer.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': detailer.is_favourite}
|
||||||
|
return redirect(url_for('detailer_detail', slug=slug))
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import logging
|
|||||||
|
|
||||||
from flask import render_template, request, current_app
|
from flask import render_template, request, current_app
|
||||||
from models import (
|
from models import (
|
||||||
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint,
|
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint, Preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints']
|
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints', 'looks', 'presets', 'generator']
|
||||||
|
|
||||||
_MODEL_MAP = {
|
_MODEL_MAP = {
|
||||||
'characters': Character,
|
'characters': Character,
|
||||||
@@ -20,11 +20,36 @@ _MODEL_MAP = {
|
|||||||
'styles': Style,
|
'styles': Style,
|
||||||
'detailers': Detailer,
|
'detailers': Detailer,
|
||||||
'checkpoints': Checkpoint,
|
'checkpoints': Checkpoint,
|
||||||
|
'looks': Look,
|
||||||
|
'presets': Preset,
|
||||||
|
'generator': Preset,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Maps xref_category param names to sidecar JSON keys
|
||||||
|
_XREF_KEY_MAP = {
|
||||||
|
'character': 'character_slug',
|
||||||
|
'outfit': 'outfit_slug',
|
||||||
|
'action': 'action_slug',
|
||||||
|
'style': 'style_slug',
|
||||||
|
'scene': 'scene_slug',
|
||||||
|
'detailer': 'detailer_slug',
|
||||||
|
'look': 'look_slug',
|
||||||
|
'preset': 'preset_slug',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
|
||||||
|
def _read_sidecar(upload_folder, image_path):
|
||||||
|
"""Read JSON sidecar for an image. Returns dict or None."""
|
||||||
|
sidecar = image_path.rsplit('.', 1)[0] + '.json'
|
||||||
|
sidecar_path = os.path.join(upload_folder, sidecar)
|
||||||
|
try:
|
||||||
|
with open(sidecar_path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
def _scan_gallery_images(category_filter='all', slug_filter=''):
|
def _scan_gallery_images(category_filter='all', slug_filter=''):
|
||||||
"""Return sorted list of image dicts from the uploads directory."""
|
"""Return sorted list of image dicts from the uploads directory."""
|
||||||
upload_folder = app.config['UPLOAD_FOLDER']
|
upload_folder = app.config['UPLOAD_FOLDER']
|
||||||
@@ -164,18 +189,48 @@ def register_routes(app):
|
|||||||
category = request.args.get('category', 'all')
|
category = request.args.get('category', 'all')
|
||||||
slug = request.args.get('slug', '')
|
slug = request.args.get('slug', '')
|
||||||
sort = request.args.get('sort', 'newest')
|
sort = request.args.get('sort', 'newest')
|
||||||
|
xref_category = request.args.get('xref_category', '')
|
||||||
|
xref_slug = request.args.get('xref_slug', '')
|
||||||
|
favourite_filter = request.args.get('favourite', '')
|
||||||
|
nsfw_filter = request.args.get('nsfw', 'all')
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
per_page = int(request.args.get('per_page', 48))
|
per_page = int(request.args.get('per_page', 48))
|
||||||
per_page = per_page if per_page in (24, 48, 96) else 48
|
per_page = per_page if per_page in (24, 48, 96) else 48
|
||||||
|
|
||||||
images = _scan_gallery_images(category, slug)
|
images = _scan_gallery_images(category, slug)
|
||||||
|
|
||||||
|
# Read sidecar data for filtering (favourite/NSFW/xref)
|
||||||
|
upload_folder = app.config['UPLOAD_FOLDER']
|
||||||
|
need_sidecar = (xref_category and xref_slug) or favourite_filter or nsfw_filter != 'all'
|
||||||
|
if need_sidecar:
|
||||||
|
for img in images:
|
||||||
|
img['_sidecar'] = _read_sidecar(upload_folder, img['path']) or {}
|
||||||
|
|
||||||
|
# Cross-reference filter
|
||||||
|
if xref_category and xref_slug and xref_category in _XREF_KEY_MAP:
|
||||||
|
sidecar_key = _XREF_KEY_MAP[xref_category]
|
||||||
|
images = [img for img in images if img.get('_sidecar', {}).get(sidecar_key) == xref_slug]
|
||||||
|
|
||||||
|
# Favourite filter
|
||||||
|
if favourite_filter == 'on':
|
||||||
|
images = [img for img in images if img.get('_sidecar', {}).get('is_favourite')]
|
||||||
|
|
||||||
|
# NSFW filter
|
||||||
|
if nsfw_filter == 'sfw':
|
||||||
|
images = [img for img in images if not img.get('_sidecar', {}).get('is_nsfw')]
|
||||||
|
elif nsfw_filter == 'nsfw':
|
||||||
|
images = [img for img in images if img.get('_sidecar', {}).get('is_nsfw')]
|
||||||
|
|
||||||
if sort == 'oldest':
|
if sort == 'oldest':
|
||||||
images.reverse()
|
images.reverse()
|
||||||
elif sort == 'random':
|
elif sort == 'random':
|
||||||
import random
|
import random
|
||||||
random.shuffle(images)
|
random.shuffle(images)
|
||||||
|
|
||||||
|
# Sort favourites first when favourite filter not active but sort is newest/oldest
|
||||||
|
if sort in ('newest', 'oldest') and not favourite_filter and need_sidecar:
|
||||||
|
images.sort(key=lambda x: (not x.get('_sidecar', {}).get('is_favourite', False), images.index(x)))
|
||||||
|
|
||||||
total = len(images)
|
total = len(images)
|
||||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||||
page = min(page, total_pages)
|
page = min(page, total_pages)
|
||||||
@@ -197,6 +252,11 @@ def register_routes(app):
|
|||||||
if Model:
|
if Model:
|
||||||
slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()]
|
slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()]
|
||||||
|
|
||||||
|
# Attach sidecar data to page images for template use
|
||||||
|
for img in page_images:
|
||||||
|
if '_sidecar' not in img:
|
||||||
|
img['_sidecar'] = _read_sidecar(os.path.abspath(app.config['UPLOAD_FOLDER']), img['path']) or {}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'gallery.html',
|
'gallery.html',
|
||||||
images=page_images,
|
images=page_images,
|
||||||
@@ -209,6 +269,10 @@ def register_routes(app):
|
|||||||
sort=sort,
|
sort=sort,
|
||||||
categories=GALLERY_CATEGORIES,
|
categories=GALLERY_CATEGORIES,
|
||||||
slug_options=slug_options,
|
slug_options=slug_options,
|
||||||
|
xref_category=xref_category,
|
||||||
|
xref_slug=xref_slug,
|
||||||
|
favourite_filter=favourite_filter,
|
||||||
|
nsfw_filter=nsfw_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/gallery/prompt-data')
|
@app.route('/gallery/prompt-data')
|
||||||
@@ -228,8 +292,60 @@ def register_routes(app):
|
|||||||
|
|
||||||
meta = _parse_comfy_png_metadata(abs_img)
|
meta = _parse_comfy_png_metadata(abs_img)
|
||||||
meta['path'] = img_path
|
meta['path'] = img_path
|
||||||
|
|
||||||
|
# Include sidecar data if available (for cross-reference links)
|
||||||
|
sidecar = _read_sidecar(upload_folder, img_path)
|
||||||
|
if sidecar:
|
||||||
|
meta['sidecar'] = sidecar
|
||||||
|
|
||||||
return meta
|
return meta
|
||||||
|
|
||||||
|
def _write_sidecar(upload_folder, image_path, data):
|
||||||
|
"""Write/update JSON sidecar for an image."""
|
||||||
|
sidecar = image_path.rsplit('.', 1)[0] + '.json'
|
||||||
|
sidecar_path = os.path.join(upload_folder, sidecar)
|
||||||
|
existing = {}
|
||||||
|
try:
|
||||||
|
with open(sidecar_path) as f:
|
||||||
|
existing = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
existing.update(data)
|
||||||
|
with open(sidecar_path, 'w') as f:
|
||||||
|
json.dump(existing, f, indent=2)
|
||||||
|
|
||||||
|
@app.route('/gallery/image/favourite', methods=['POST'])
|
||||||
|
def gallery_image_favourite():
|
||||||
|
"""Toggle favourite on a gallery image via sidecar JSON."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
img_path = data.get('path', '')
|
||||||
|
if not img_path:
|
||||||
|
return {'error': 'path required'}, 400
|
||||||
|
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
|
||||||
|
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
|
||||||
|
if not abs_img.startswith(upload_folder + os.sep) or not os.path.isfile(abs_img):
|
||||||
|
return {'error': 'Invalid path'}, 400
|
||||||
|
sidecar = _read_sidecar(upload_folder, img_path) or {}
|
||||||
|
new_val = not sidecar.get('is_favourite', False)
|
||||||
|
_write_sidecar(upload_folder, img_path, {'is_favourite': new_val})
|
||||||
|
return {'success': True, 'is_favourite': new_val}
|
||||||
|
|
||||||
|
@app.route('/gallery/image/nsfw', methods=['POST'])
|
||||||
|
def gallery_image_nsfw():
|
||||||
|
"""Toggle NSFW on a gallery image via sidecar JSON."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
img_path = data.get('path', '')
|
||||||
|
if not img_path:
|
||||||
|
return {'error': 'path required'}, 400
|
||||||
|
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
|
||||||
|
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
|
||||||
|
if not abs_img.startswith(upload_folder + os.sep) or not os.path.isfile(abs_img):
|
||||||
|
return {'error': 'Invalid path'}, 400
|
||||||
|
sidecar = _read_sidecar(upload_folder, img_path) or {}
|
||||||
|
new_val = not sidecar.get('is_nsfw', False)
|
||||||
|
_write_sidecar(upload_folder, img_path, {'is_nsfw': new_val})
|
||||||
|
return {'success': True, 'is_nsfw': new_val}
|
||||||
|
|
||||||
@app.route('/gallery/delete', methods=['POST'])
|
@app.route('/gallery/delete', methods=['POST'])
|
||||||
def gallery_delete():
|
def gallery_delete():
|
||||||
"""Delete a generated image from the gallery. Only the image file is removed."""
|
"""Delete a generated image from the gallery. Only the image file is removed."""
|
||||||
@@ -249,6 +365,10 @@ def register_routes(app):
|
|||||||
|
|
||||||
if os.path.isfile(abs_img):
|
if os.path.isfile(abs_img):
|
||||||
os.remove(abs_img)
|
os.remove(abs_img)
|
||||||
|
# Also remove sidecar JSON if present
|
||||||
|
sidecar = abs_img.rsplit('.', 1)[0] + '.json'
|
||||||
|
if os.path.isfile(sidecar):
|
||||||
|
os.remove(sidecar)
|
||||||
|
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
@@ -260,6 +380,7 @@ def register_routes(app):
|
|||||||
hard: removes JSON data file + LoRA/checkpoint safetensors + DB record.
|
hard: removes JSON data file + LoRA/checkpoint safetensors + DB record.
|
||||||
"""
|
"""
|
||||||
_RESOURCE_MODEL_MAP = {
|
_RESOURCE_MODEL_MAP = {
|
||||||
|
'characters': Character,
|
||||||
'looks': Look,
|
'looks': Look,
|
||||||
'styles': Style,
|
'styles': Style,
|
||||||
'actions': Action,
|
'actions': Action,
|
||||||
@@ -269,6 +390,7 @@ def register_routes(app):
|
|||||||
'checkpoints': Checkpoint,
|
'checkpoints': Checkpoint,
|
||||||
}
|
}
|
||||||
_RESOURCE_DATA_DIRS = {
|
_RESOURCE_DATA_DIRS = {
|
||||||
|
'characters': app.config['CHARACTERS_DIR'],
|
||||||
'looks': app.config['LOOKS_DIR'],
|
'looks': app.config['LOOKS_DIR'],
|
||||||
'styles': app.config['STYLES_DIR'],
|
'styles': app.config['STYLES_DIR'],
|
||||||
'actions': app.config['ACTIONS_DIR'],
|
'actions': app.config['ACTIONS_DIR'],
|
||||||
|
|||||||
@@ -1,154 +1,135 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
|
||||||
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint
|
from flask import render_template, request, redirect, url_for, flash
|
||||||
from services.prompts import build_prompt, build_extras_prompt
|
from models import Preset
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.generation import generate_from_preset
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
|
||||||
from services.file_io import get_available_checkpoints
|
from services.file_io import get_available_checkpoints
|
||||||
from services.comfyui import get_loaded_checkpoint
|
from services.comfyui import get_loaded_checkpoint
|
||||||
|
from services.workflow import _get_default_checkpoint
|
||||||
|
from services.sync import _resolve_preset_entity
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
|
||||||
@app.route('/generator', methods=['GET', 'POST'])
|
@app.route('/generator', methods=['GET'])
|
||||||
def generator():
|
def generator():
|
||||||
characters = Character.query.order_by(Character.name).all()
|
presets = Preset.query.order_by(Preset.name).all()
|
||||||
checkpoints = get_available_checkpoints()
|
checkpoints = get_available_checkpoints()
|
||||||
actions = Action.query.order_by(Action.name).all()
|
|
||||||
outfits = Outfit.query.order_by(Outfit.name).all()
|
|
||||||
scenes = Scene.query.order_by(Scene.name).all()
|
|
||||||
styles = Style.query.order_by(Style.name).all()
|
|
||||||
detailers = Detailer.query.order_by(Detailer.name).all()
|
|
||||||
|
|
||||||
if not checkpoints:
|
|
||||||
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
|
|
||||||
|
|
||||||
# Default to whatever is currently loaded in ComfyUI, then settings default
|
|
||||||
selected_ckpt = get_loaded_checkpoint()
|
selected_ckpt = get_loaded_checkpoint()
|
||||||
if not selected_ckpt:
|
if not selected_ckpt:
|
||||||
default_path, _ = _get_default_checkpoint()
|
default_path, _ = _get_default_checkpoint()
|
||||||
selected_ckpt = default_path
|
selected_ckpt = default_path
|
||||||
|
|
||||||
if request.method == 'POST':
|
# Pre-select preset from query param
|
||||||
char_slug = request.form.get('character')
|
preset_slug = request.args.get('preset', '')
|
||||||
checkpoint = request.form.get('checkpoint')
|
|
||||||
custom_positive = request.form.get('positive_prompt', '')
|
|
||||||
custom_negative = request.form.get('negative_prompt', '')
|
|
||||||
|
|
||||||
action_slugs = request.form.getlist('action_slugs')
|
return render_template('generator.html',
|
||||||
outfit_slugs = request.form.getlist('outfit_slugs')
|
presets=presets,
|
||||||
scene_slugs = request.form.getlist('scene_slugs')
|
checkpoints=checkpoints,
|
||||||
style_slugs = request.form.getlist('style_slugs')
|
selected_ckpt=selected_ckpt,
|
||||||
detailer_slugs = request.form.getlist('detailer_slugs')
|
preset_slug=preset_slug)
|
||||||
override_prompt = request.form.get('override_prompt', '').strip()
|
|
||||||
width = request.form.get('width') or 1024
|
|
||||||
height = request.form.get('height') or 1024
|
|
||||||
|
|
||||||
character = Character.query.filter_by(slug=char_slug).first_or_404()
|
@app.route('/generator/generate', methods=['POST'])
|
||||||
|
def generator_generate():
|
||||||
|
preset_slug = request.form.get('preset_slug', '').strip()
|
||||||
|
if not preset_slug:
|
||||||
|
return {'error': 'No preset selected'}, 400
|
||||||
|
|
||||||
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
|
preset = Preset.query.filter_by(slug=preset_slug).first()
|
||||||
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
|
if not preset:
|
||||||
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
|
return {'error': 'Preset not found'}, 404
|
||||||
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
|
|
||||||
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open('comfy_workflow.json', 'r') as f:
|
overrides = {
|
||||||
workflow = json.load(f)
|
'checkpoint': request.form.get('checkpoint', '').strip() or None,
|
||||||
|
'extra_positive': request.form.get('extra_positive', '').strip(),
|
||||||
|
'extra_negative': request.form.get('extra_negative', '').strip(),
|
||||||
|
'action': 'preview',
|
||||||
|
}
|
||||||
|
|
||||||
# Build base prompts from character defaults
|
|
||||||
prompts = build_prompt(character.data, default_fields=character.default_fields)
|
|
||||||
|
|
||||||
if override_prompt:
|
|
||||||
prompts["main"] = override_prompt
|
|
||||||
else:
|
|
||||||
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
|
|
||||||
combined = prompts["main"]
|
|
||||||
if extras:
|
|
||||||
combined = f"{combined}, {extras}"
|
|
||||||
if custom_positive:
|
|
||||||
combined = f"{custom_positive}, {combined}"
|
|
||||||
prompts["main"] = combined
|
|
||||||
|
|
||||||
# Apply face/hand prompt overrides if provided
|
|
||||||
override_face = request.form.get('override_face_prompt', '').strip()
|
|
||||||
override_hand = request.form.get('override_hand_prompt', '').strip()
|
|
||||||
if override_face:
|
|
||||||
prompts["face"] = override_face
|
|
||||||
if override_hand:
|
|
||||||
prompts["hand"] = override_hand
|
|
||||||
|
|
||||||
# Parse optional seed
|
|
||||||
seed_val = request.form.get('seed', '').strip()
|
seed_val = request.form.get('seed', '').strip()
|
||||||
fixed_seed = int(seed_val) if seed_val else None
|
if seed_val:
|
||||||
|
overrides['seed'] = int(seed_val)
|
||||||
|
|
||||||
# Prepare workflow - first selected item per category supplies its LoRA slot
|
width = request.form.get('width', '').strip()
|
||||||
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint).first() if checkpoint else None
|
height = request.form.get('height', '').strip()
|
||||||
workflow = _prepare_workflow(
|
if width and height:
|
||||||
workflow, character, prompts, checkpoint, custom_negative,
|
overrides['width'] = int(width)
|
||||||
outfit=sel_outfits[0] if sel_outfits else None,
|
overrides['height'] = int(height)
|
||||||
action=sel_actions[0] if sel_actions else None,
|
|
||||||
style=sel_styles[0] if sel_styles else None,
|
|
||||||
detailer=sel_detailers[0] if sel_detailers else None,
|
|
||||||
scene=sel_scenes[0] if sel_scenes else None,
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
checkpoint_data=ckpt_obj.data if ckpt_obj else None,
|
|
||||||
fixed_seed=fixed_seed,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"Queueing generator prompt for {character.character_id}")
|
job = generate_from_preset(preset, overrides, save_category='generator')
|
||||||
|
|
||||||
_finalize = _make_finalize('characters', character.slug)
|
|
||||||
label = f"Generator: {character.name}"
|
|
||||||
job = _enqueue_job(label, workflow, _finalize)
|
|
||||||
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'status': 'queued', 'job_id': job['id']}
|
return {'status': 'queued', 'job_id': job['id']}
|
||||||
|
|
||||||
flash("Generation queued.")
|
flash("Generation queued.")
|
||||||
|
return redirect(url_for('generator', preset=preset_slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generator error: {e}")
|
logger.exception("Generator error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error: {str(e)}")
|
flash(f"Error: {str(e)}")
|
||||||
|
return redirect(url_for('generator', preset=preset_slug))
|
||||||
|
|
||||||
return render_template('generator.html', characters=characters, checkpoints=checkpoints,
|
@app.route('/generator/preset_info', methods=['GET'])
|
||||||
actions=actions, outfits=outfits, scenes=scenes,
|
def generator_preset_info():
|
||||||
styles=styles, detailers=detailers, selected_ckpt=selected_ckpt)
|
"""Return resolved entity names for a preset (for the summary panel)."""
|
||||||
|
slug = request.args.get('slug', '')
|
||||||
|
if not slug:
|
||||||
|
return {'error': 'slug required'}, 400
|
||||||
|
|
||||||
@app.route('/generator/preview_prompt', methods=['POST'])
|
preset = Preset.query.filter_by(slug=slug).first()
|
||||||
def generator_preview_prompt():
|
if not preset:
|
||||||
char_slug = request.form.get('character')
|
return {'error': 'not found'}, 404
|
||||||
if not char_slug:
|
|
||||||
return {'error': 'No character selected'}, 400
|
|
||||||
|
|
||||||
character = Character.query.filter_by(slug=char_slug).first()
|
data = preset.data
|
||||||
if not character:
|
info = {}
|
||||||
return {'error': 'Character not found'}, 404
|
|
||||||
|
|
||||||
action_slugs = request.form.getlist('action_slugs')
|
# Character
|
||||||
outfit_slugs = request.form.getlist('outfit_slugs')
|
char_cfg = data.get('character', {})
|
||||||
scene_slugs = request.form.getlist('scene_slugs')
|
char_id = char_cfg.get('character_id')
|
||||||
style_slugs = request.form.getlist('style_slugs')
|
if char_id == 'random':
|
||||||
detailer_slugs = request.form.getlist('detailer_slugs')
|
info['character'] = 'Random'
|
||||||
custom_positive = request.form.get('positive_prompt', '')
|
elif char_id:
|
||||||
|
obj = _resolve_preset_entity('character', char_id)
|
||||||
|
info['character'] = obj.name if obj else char_id
|
||||||
|
else:
|
||||||
|
info['character'] = None
|
||||||
|
|
||||||
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
|
# Secondary entities
|
||||||
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
|
for key, label in [('outfit', 'outfit'), ('action', 'action'), ('style', 'style'),
|
||||||
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
|
('scene', 'scene'), ('detailer', 'detailer'), ('look', 'look')]:
|
||||||
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
|
cfg = data.get(key, {})
|
||||||
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
|
eid = cfg.get(f'{key}_id')
|
||||||
|
if eid == 'random':
|
||||||
|
info[label] = 'Random'
|
||||||
|
elif eid:
|
||||||
|
obj = _resolve_preset_entity(key, eid)
|
||||||
|
info[label] = obj.name if obj else eid
|
||||||
|
else:
|
||||||
|
info[label] = None
|
||||||
|
|
||||||
prompts = build_prompt(character.data, default_fields=character.default_fields)
|
# Checkpoint
|
||||||
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
|
ckpt_cfg = data.get('checkpoint', {})
|
||||||
combined = prompts["main"]
|
ckpt_path = ckpt_cfg.get('checkpoint_path')
|
||||||
if extras:
|
if ckpt_path == 'random':
|
||||||
combined = f"{combined}, {extras}"
|
info['checkpoint'] = 'Random'
|
||||||
if custom_positive:
|
elif ckpt_path:
|
||||||
combined = f"{custom_positive}, {combined}"
|
info['checkpoint'] = ckpt_path.split('/')[-1].replace('.safetensors', '')
|
||||||
|
else:
|
||||||
|
info['checkpoint'] = 'Default'
|
||||||
|
|
||||||
return {'prompt': combined, 'face': prompts['face'], 'hand': prompts['hand']}
|
# Resolution
|
||||||
|
res_cfg = data.get('resolution', {})
|
||||||
|
if res_cfg.get('random'):
|
||||||
|
info['resolution'] = 'Random'
|
||||||
|
elif res_cfg.get('width') and res_cfg.get('height'):
|
||||||
|
info['resolution'] = f"{res_cfg['width']}x{res_cfg['height']}"
|
||||||
|
else:
|
||||||
|
info['resolution'] = 'Default'
|
||||||
|
|
||||||
|
return info
|
||||||
|
|||||||
111
routes/looks.py
111
routes/looks.py
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
|
|
||||||
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
|
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags
|
||||||
from services.sync import sync_looks
|
from services.sync import sync_looks
|
||||||
from services.file_io import get_available_loras, _count_look_assignments
|
from services.file_io import get_available_loras, _count_look_assignments
|
||||||
@@ -58,9 +57,18 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/looks')
|
@app.route('/looks')
|
||||||
def looks_index():
|
def looks_index():
|
||||||
looks = Look.query.order_by(Look.name).all()
|
query = Look.query
|
||||||
|
fav = request.args.get('favourite')
|
||||||
|
nsfw = request.args.get('nsfw', 'all')
|
||||||
|
if fav == 'on':
|
||||||
|
query = query.filter_by(is_favourite=True)
|
||||||
|
if nsfw == 'sfw':
|
||||||
|
query = query.filter_by(is_nsfw=False)
|
||||||
|
elif nsfw == 'nsfw':
|
||||||
|
query = query.filter_by(is_nsfw=True)
|
||||||
|
looks = query.order_by(Look.is_favourite.desc(), Look.name).all()
|
||||||
look_assignments = _count_look_assignments()
|
look_assignments = _count_look_assignments()
|
||||||
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments)
|
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||||
|
|
||||||
@app.route('/looks/rescan', methods=['POST'])
|
@app.route('/looks/rescan', methods=['POST'])
|
||||||
def rescan_looks():
|
def rescan_looks():
|
||||||
@@ -144,8 +152,12 @@ def register_routes(app):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
tags_raw = request.form.get('tags', '')
|
new_data['tags'] = {
|
||||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
'origin_series': request.form.get('tag_origin_series', '').strip(),
|
||||||
|
'origin_type': request.form.get('tag_origin_type', '').strip(),
|
||||||
|
'nsfw': 'tag_nsfw' in request.form,
|
||||||
|
}
|
||||||
|
look.is_nsfw = new_data['tags']['nsfw']
|
||||||
|
|
||||||
look.data = new_data
|
look.data = new_data
|
||||||
flag_modified(look, 'data')
|
flag_modified(look, 'data')
|
||||||
@@ -435,19 +447,32 @@ Character ID: {character_slug}"""
|
|||||||
def create_look():
|
def create_look():
|
||||||
characters = Character.query.order_by(Character.name).all()
|
characters = Character.query.order_by(Character.name).all()
|
||||||
loras = get_available_loras('characters')
|
loras = get_available_loras('characters')
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name', '').strip()
|
name = request.form.get('name', '').strip()
|
||||||
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
|
|
||||||
filename = f'{look_id}.json'
|
|
||||||
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
|
|
||||||
|
|
||||||
character_id = request.form.get('character_id', '') or None
|
character_id = request.form.get('character_id', '') or None
|
||||||
lora_name = request.form.get('lora_lora_name', '')
|
lora_name = request.form.get('lora_lora_name', '')
|
||||||
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
|
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
|
||||||
lora_triggers = request.form.get('lora_lora_triggers', '')
|
lora_triggers = request.form.get('lora_lora_triggers', '')
|
||||||
positive = request.form.get('positive', '')
|
positive = request.form.get('positive', '')
|
||||||
negative = request.form.get('negative', '')
|
negative = request.form.get('negative', '')
|
||||||
tags = [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()]
|
tags = {
|
||||||
|
'origin_series': request.form.get('tag_origin_series', '').strip(),
|
||||||
|
'origin_type': request.form.get('tag_origin_type', '').strip(),
|
||||||
|
'nsfw': 'tag_nsfw' in request.form,
|
||||||
|
}
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
'name': name, 'character_id': character_id,
|
||||||
|
'lora_lora_name': lora_name, 'lora_lora_weight': lora_weight,
|
||||||
|
'lora_lora_triggers': lora_triggers, 'positive': positive,
|
||||||
|
'negative': negative, 'tags': tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
|
||||||
|
filename = f'{look_id}.json'
|
||||||
|
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'look_id': look_id,
|
'look_id': look_id,
|
||||||
@@ -459,20 +484,26 @@ Character ID: {character_slug}"""
|
|||||||
'tags': tags
|
'tags': tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
|
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
|
||||||
with open(file_path, 'w') as f:
|
with open(file_path, 'w') as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
|
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
|
||||||
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
|
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
|
||||||
character_id=character_id, data=data)
|
character_id=character_id, data=data,
|
||||||
|
is_nsfw=tags.get('nsfw', False))
|
||||||
db.session.add(new_look)
|
db.session.add(new_look)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash(f'Look "{name}" created!')
|
flash(f'Look "{name}" created!')
|
||||||
return redirect(url_for('look_detail', slug=slug))
|
return redirect(url_for('look_detail', slug=slug))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Save error: {e}")
|
||||||
|
flash(f"Failed to create look: {e}")
|
||||||
|
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
||||||
|
|
||||||
return render_template('looks/create.html', characters=characters, loras=loras)
|
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
||||||
|
|
||||||
@app.route('/get_missing_looks')
|
@app.route('/get_missing_looks')
|
||||||
def get_missing_looks():
|
def get_missing_looks():
|
||||||
@@ -497,15 +528,16 @@ Character ID: {character_slug}"""
|
|||||||
return redirect(url_for('looks_index'))
|
return redirect(url_for('looks_index'))
|
||||||
|
|
||||||
overwrite = request.form.get('overwrite') == 'true'
|
overwrite = request.form.get('overwrite') == 'true'
|
||||||
created_count = 0
|
skipped = 0
|
||||||
skipped_count = 0
|
job_ids = []
|
||||||
overwritten_count = 0
|
|
||||||
|
|
||||||
system_prompt = load_prompt('look_system.txt')
|
system_prompt = load_prompt('look_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
flash('Look system prompt file not found.', 'error')
|
flash('Look system prompt file not found.', 'error')
|
||||||
return redirect(url_for('looks_index'))
|
return redirect(url_for('looks_index'))
|
||||||
|
|
||||||
|
looks_dir = app.config['LOOKS_DIR']
|
||||||
|
|
||||||
for filename in os.listdir(lora_dir):
|
for filename in os.listdir(lora_dir):
|
||||||
if not filename.endswith('.safetensors'):
|
if not filename.endswith('.safetensors'):
|
||||||
continue
|
continue
|
||||||
@@ -515,11 +547,11 @@ Character ID: {character_slug}"""
|
|||||||
look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||||
|
|
||||||
json_filename = f"{look_id}.json"
|
json_filename = f"{look_id}.json"
|
||||||
json_path = os.path.join(app.config['LOOKS_DIR'], json_filename)
|
json_path = os.path.join(looks_dir, json_filename)
|
||||||
|
|
||||||
is_existing = os.path.exists(json_path)
|
is_existing = os.path.exists(json_path)
|
||||||
if is_existing and not overwrite:
|
if is_existing and not overwrite:
|
||||||
skipped_count += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
html_filename = f"{name_base}.html"
|
html_filename = f"{name_base}.html"
|
||||||
@@ -535,10 +567,10 @@ Character ID: {character_slug}"""
|
|||||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||||
html_content = ' '.join(clean_html.split())
|
html_content = ' '.join(clean_html.split())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading HTML {html_filename}: {e}")
|
logger.error("Error reading HTML %s: %s", html_filename, e)
|
||||||
|
|
||||||
try:
|
def make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||||
print(f"Asking LLM to describe look: {look_name}")
|
def task_fn(job):
|
||||||
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
|
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
|
||||||
if html_content:
|
if html_content:
|
||||||
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
||||||
@@ -562,27 +594,32 @@ Character ID: {character_slug}"""
|
|||||||
if look_data['lora'].get('lora_weight_max') is None:
|
if look_data['lora'].get('lora_weight_max') is None:
|
||||||
look_data['lora']['lora_weight_max'] = 1.0
|
look_data['lora']['lora_weight_max'] = 1.0
|
||||||
|
|
||||||
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
|
os.makedirs(looks_dir, exist_ok=True)
|
||||||
with open(json_path, 'w') as f:
|
with open(json_path, 'w') as f:
|
||||||
json.dump(look_data, f, indent=2)
|
json.dump(look_data, f, indent=2)
|
||||||
|
|
||||||
if is_existing:
|
job['result'] = {'name': look_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||||
overwritten_count += 1
|
return task_fn
|
||||||
else:
|
|
||||||
created_count += 1
|
|
||||||
|
|
||||||
time.sleep(0.5)
|
job = _enqueue_task(f"Create look: {look_name}", make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||||
|
job_ids.append(job['id'])
|
||||||
|
|
||||||
except Exception as e:
|
if job_ids:
|
||||||
print(f"Error creating look for {filename}: {e}")
|
def sync_task(job):
|
||||||
|
|
||||||
if created_count > 0 or overwritten_count > 0:
|
|
||||||
sync_looks()
|
sync_looks()
|
||||||
msg = f'Successfully processed looks: {created_count} created, {overwritten_count} overwritten.'
|
job['result'] = {'synced': True}
|
||||||
if skipped_count > 0:
|
_enqueue_task("Sync looks DB", sync_task)
|
||||||
msg += f' (Skipped {skipped_count} existing)'
|
|
||||||
flash(msg)
|
|
||||||
else:
|
|
||||||
flash(f'No looks created or overwritten. {skipped_count} existing entries found.')
|
|
||||||
|
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||||
|
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
|
||||||
return redirect(url_for('looks_index'))
|
return redirect(url_for('looks_index'))
|
||||||
|
|
||||||
|
@app.route('/look/<path:slug>/favourite', methods=['POST'])
|
||||||
|
def toggle_look_favourite(slug):
|
||||||
|
look = Look.query.filter_by(slug=slug).first_or_404()
|
||||||
|
look.is_favourite = not look.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': look.is_favourite}
|
||||||
|
return redirect(url_for('look_detail', slug=slug))
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
|
|
||||||
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint, Settings, Look
|
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint, Settings, Look
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||||
from services.sync import sync_outfits
|
from services.sync import sync_outfits
|
||||||
from services.file_io import get_available_loras, _count_outfit_lora_assignments
|
from services.file_io import get_available_loras, _count_outfit_lora_assignments
|
||||||
@@ -37,9 +36,18 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/outfits')
|
@app.route('/outfits')
|
||||||
def outfits_index():
|
def outfits_index():
|
||||||
outfits = Outfit.query.order_by(Outfit.name).all()
|
query = Outfit.query
|
||||||
|
fav = request.args.get('favourite')
|
||||||
|
nsfw = request.args.get('nsfw', 'all')
|
||||||
|
if fav == 'on':
|
||||||
|
query = query.filter_by(is_favourite=True)
|
||||||
|
if nsfw == 'sfw':
|
||||||
|
query = query.filter_by(is_nsfw=False)
|
||||||
|
elif nsfw == 'nsfw':
|
||||||
|
query = query.filter_by(is_nsfw=True)
|
||||||
|
outfits = query.order_by(Outfit.is_favourite.desc(), Outfit.name).all()
|
||||||
lora_assignments = _count_outfit_lora_assignments()
|
lora_assignments = _count_outfit_lora_assignments()
|
||||||
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments)
|
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||||
|
|
||||||
@app.route('/outfits/rescan', methods=['POST'])
|
@app.route('/outfits/rescan', methods=['POST'])
|
||||||
def rescan_outfits():
|
def rescan_outfits():
|
||||||
@@ -53,20 +61,24 @@ def register_routes(app):
|
|||||||
clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/')
|
clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/')
|
||||||
_lora_subfolder = os.path.basename(clothing_lora_dir)
|
_lora_subfolder = os.path.basename(clothing_lora_dir)
|
||||||
if not os.path.exists(clothing_lora_dir):
|
if not os.path.exists(clothing_lora_dir):
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'error': 'Clothing LoRA directory not found.'}, 400
|
||||||
flash('Clothing LoRA directory not found.', 'error')
|
flash('Clothing LoRA directory not found.', 'error')
|
||||||
return redirect(url_for('outfits_index'))
|
return redirect(url_for('outfits_index'))
|
||||||
|
|
||||||
overwrite = request.form.get('overwrite') == 'true'
|
overwrite = request.form.get('overwrite') == 'true'
|
||||||
created_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
overwritten_count = 0
|
|
||||||
|
|
||||||
system_prompt = load_prompt('outfit_system.txt')
|
system_prompt = load_prompt('outfit_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'error': 'Outfit system prompt file not found.'}, 500
|
||||||
flash('Outfit system prompt file not found.', 'error')
|
flash('Outfit system prompt file not found.', 'error')
|
||||||
return redirect(url_for('outfits_index'))
|
return redirect(url_for('outfits_index'))
|
||||||
|
|
||||||
for filename in os.listdir(clothing_lora_dir):
|
job_ids = []
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for filename in sorted(os.listdir(clothing_lora_dir)):
|
||||||
if not filename.endswith('.safetensors'):
|
if not filename.endswith('.safetensors'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -79,11 +91,11 @@ def register_routes(app):
|
|||||||
|
|
||||||
is_existing = os.path.exists(json_path)
|
is_existing = os.path.exists(json_path)
|
||||||
if is_existing and not overwrite:
|
if is_existing and not overwrite:
|
||||||
skipped_count += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
html_filename = f"{name_base}.html"
|
# Read HTML companion file if it exists
|
||||||
html_path = os.path.join(clothing_lora_dir, html_filename)
|
html_path = os.path.join(clothing_lora_dir, f"{name_base}.html")
|
||||||
html_content = ""
|
html_content = ""
|
||||||
if os.path.exists(html_path):
|
if os.path.exists(html_path):
|
||||||
try:
|
try:
|
||||||
@@ -94,27 +106,27 @@ def register_routes(app):
|
|||||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||||
html_content = ' '.join(clean_html.split())
|
html_content = ' '.join(clean_html.split())
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f"Error reading HTML {html_filename}: {e}")
|
pass
|
||||||
|
|
||||||
try:
|
def make_task(fn, oid, oname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||||
print(f"Asking LLM to describe outfit: {outfit_name}")
|
def task_fn(job):
|
||||||
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{filename}'"
|
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{fn}'"
|
||||||
if html_content:
|
if html_ctx:
|
||||||
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_ctx[:3000]}\n###"
|
||||||
|
|
||||||
llm_response = call_llm(prompt, system_prompt)
|
llm_response = call_llm(prompt, sys_prompt)
|
||||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||||
outfit_data = json.loads(clean_json)
|
outfit_data = json.loads(clean_json)
|
||||||
|
|
||||||
outfit_data['outfit_id'] = outfit_id
|
outfit_data['outfit_id'] = oid
|
||||||
outfit_data['outfit_name'] = outfit_name
|
outfit_data['outfit_name'] = oname
|
||||||
|
|
||||||
if 'lora' not in outfit_data:
|
if 'lora' not in outfit_data:
|
||||||
outfit_data['lora'] = {}
|
outfit_data['lora'] = {}
|
||||||
outfit_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
outfit_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||||
if not outfit_data['lora'].get('lora_triggers'):
|
if not outfit_data['lora'].get('lora_triggers'):
|
||||||
outfit_data['lora']['lora_triggers'] = name_base
|
outfit_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||||
if outfit_data['lora'].get('lora_weight') is None:
|
if outfit_data['lora'].get('lora_weight') is None:
|
||||||
outfit_data['lora']['lora_weight'] = 0.8
|
outfit_data['lora']['lora_weight'] = 0.8
|
||||||
if outfit_data['lora'].get('lora_weight_min') is None:
|
if outfit_data['lora'].get('lora_weight_min') is None:
|
||||||
@@ -122,29 +134,31 @@ def register_routes(app):
|
|||||||
if outfit_data['lora'].get('lora_weight_max') is None:
|
if outfit_data['lora'].get('lora_weight_max') is None:
|
||||||
outfit_data['lora']['lora_weight_max'] = 1.0
|
outfit_data['lora']['lora_weight_max'] = 1.0
|
||||||
|
|
||||||
os.makedirs(app.config['CLOTHING_DIR'], exist_ok=True)
|
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||||
with open(json_path, 'w') as f:
|
with open(jp, 'w') as f:
|
||||||
json.dump(outfit_data, f, indent=2)
|
json.dump(outfit_data, f, indent=2)
|
||||||
|
|
||||||
if is_existing:
|
job['result'] = {'name': oname, 'action': 'overwritten' if is_exist else 'created'}
|
||||||
overwritten_count += 1
|
return task_fn
|
||||||
else:
|
|
||||||
created_count += 1
|
|
||||||
|
|
||||||
time.sleep(0.5)
|
job = _enqueue_task(
|
||||||
|
f"Create outfit: {outfit_name}",
|
||||||
|
make_task(filename, outfit_id, outfit_name, json_path,
|
||||||
|
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||||
|
)
|
||||||
|
job_ids.append(job['id'])
|
||||||
|
|
||||||
except Exception as e:
|
# Enqueue a sync task to run after all creates
|
||||||
print(f"Error creating outfit for {filename}: {e}")
|
if job_ids:
|
||||||
|
def sync_task(job):
|
||||||
if created_count > 0 or overwritten_count > 0:
|
|
||||||
sync_outfits()
|
sync_outfits()
|
||||||
msg = f'Successfully processed outfits: {created_count} created, {overwritten_count} overwritten.'
|
job['result'] = {'synced': True}
|
||||||
if skipped_count > 0:
|
_enqueue_task("Sync outfits DB", sync_task)
|
||||||
msg += f' (Skipped {skipped_count} existing)'
|
|
||||||
flash(msg)
|
|
||||||
else:
|
|
||||||
flash(f'No outfits created or overwritten. {skipped_count} existing entries found.')
|
|
||||||
|
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||||
|
|
||||||
|
flash(f'Queued {len(job_ids)} outfit creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||||
return redirect(url_for('outfits_index'))
|
return redirect(url_for('outfits_index'))
|
||||||
|
|
||||||
def _get_linked_characters_for_outfit(outfit):
|
def _get_linked_characters_for_outfit(outfit):
|
||||||
@@ -232,9 +246,12 @@ def register_routes(app):
|
|||||||
else:
|
else:
|
||||||
new_data.setdefault('lora', {}).pop(bound, None)
|
new_data.setdefault('lora', {}).pop(bound, None)
|
||||||
|
|
||||||
# Update Tags (comma separated string to list)
|
# Update Tags (structured dict)
|
||||||
tags_raw = request.form.get('tags', '')
|
new_data['tags'] = {
|
||||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
'outfit_type': request.form.get('tag_outfit_type', '').strip(),
|
||||||
|
'nsfw': 'tag_nsfw' in request.form,
|
||||||
|
}
|
||||||
|
outfit.is_nsfw = new_data['tags']['nsfw']
|
||||||
|
|
||||||
outfit.data = new_data
|
outfit.data = new_data
|
||||||
flag_modified(outfit, "data")
|
flag_modified(outfit, "data")
|
||||||
@@ -409,12 +426,16 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/outfit/create', methods=['GET', 'POST'])
|
@app.route('/outfit/create', methods=['GET', 'POST'])
|
||||||
def create_outfit():
|
def create_outfit():
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name')
|
name = request.form.get('name')
|
||||||
slug = request.form.get('filename', '').strip()
|
slug = request.form.get('filename', '').strip()
|
||||||
prompt = request.form.get('prompt', '')
|
prompt = request.form.get('prompt', '')
|
||||||
use_llm = request.form.get('use_llm') == 'on'
|
use_llm = request.form.get('use_llm') == 'on'
|
||||||
|
|
||||||
|
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
|
||||||
|
|
||||||
# Auto-generate slug from name if not provided
|
# Auto-generate slug from name if not provided
|
||||||
if not slug:
|
if not slug:
|
||||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||||
@@ -435,13 +456,13 @@ def register_routes(app):
|
|||||||
if use_llm:
|
if use_llm:
|
||||||
if not prompt:
|
if not prompt:
|
||||||
flash("Description is required when AI generation is enabled.")
|
flash("Description is required when AI generation is enabled.")
|
||||||
return redirect(request.url)
|
return render_template('outfits/create.html', form_data=form_data)
|
||||||
|
|
||||||
# Generate JSON with LLM
|
# Generate JSON with LLM
|
||||||
system_prompt = load_prompt('outfit_system.txt')
|
system_prompt = load_prompt('outfit_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
flash("System prompt file not found.")
|
flash("System prompt file not found.")
|
||||||
return redirect(request.url)
|
return render_template('outfits/create.html', form_data=form_data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt)
|
llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt)
|
||||||
@@ -477,7 +498,7 @@ def register_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LLM error: {e}")
|
print(f"LLM error: {e}")
|
||||||
flash(f"Failed to generate outfit profile: {e}")
|
flash(f"Failed to generate outfit profile: {e}")
|
||||||
return redirect(request.url)
|
return render_template('outfits/create.html', form_data=form_data)
|
||||||
else:
|
else:
|
||||||
# Create blank outfit template
|
# Create blank outfit template
|
||||||
outfit_data = {
|
outfit_data = {
|
||||||
@@ -523,9 +544,9 @@ def register_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
print(f"Save error: {e}")
|
||||||
flash(f"Failed to create outfit: {e}")
|
flash(f"Failed to create outfit: {e}")
|
||||||
return redirect(request.url)
|
return render_template('outfits/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('outfits/create.html')
|
return render_template('outfits/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/outfit/<path:slug>/save_defaults', methods=['POST'])
|
@app.route('/outfit/<path:slug>/save_defaults', methods=['POST'])
|
||||||
def save_outfit_defaults(slug):
|
def save_outfit_defaults(slug):
|
||||||
@@ -601,3 +622,12 @@ def register_routes(app):
|
|||||||
with open(file_path, 'w') as f:
|
with open(file_path, 'w') as f:
|
||||||
json.dump(new_data, f, indent=2)
|
json.dump(new_data, f, indent=2)
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
|
|
||||||
|
@app.route('/outfit/<path:slug>/favourite', methods=['POST'])
|
||||||
|
def toggle_outfit_favourite(slug):
|
||||||
|
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
|
||||||
|
outfit.is_favourite = not outfit.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': outfit.is_favourite}
|
||||||
|
return redirect(url_for('outfit_detail', slug=slug))
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ def register_routes(app):
|
|||||||
'use_lora': request.form.get('outfit_use_lora') == 'on'},
|
'use_lora': request.form.get('outfit_use_lora') == 'on'},
|
||||||
'action': {'action_id': _entity_id(request.form.get('action_id')),
|
'action': {'action_id': _entity_id(request.form.get('action_id')),
|
||||||
'use_lora': request.form.get('action_use_lora') == 'on',
|
'use_lora': request.form.get('action_use_lora') == 'on',
|
||||||
|
'suppress_wardrobe': {'true': True, 'false': False, 'random': 'random'}.get(
|
||||||
|
request.form.get('act_suppress_wardrobe')),
|
||||||
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
|
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
|
||||||
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
|
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
|
||||||
'style': {'style_id': _entity_id(request.form.get('style_id')),
|
'style': {'style_id': _entity_id(request.form.get('style_id')),
|
||||||
@@ -247,11 +249,15 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/preset/create', methods=['GET', 'POST'])
|
@app.route('/preset/create', methods=['GET', 'POST'])
|
||||||
def create_preset():
|
def create_preset():
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name', '').strip()
|
name = request.form.get('name', '').strip()
|
||||||
description = request.form.get('description', '').strip()
|
description = request.form.get('description', '').strip()
|
||||||
use_llm = request.form.get('use_llm') == 'on'
|
use_llm = request.form.get('use_llm') == 'on'
|
||||||
|
|
||||||
|
form_data = {'name': name, 'description': description, 'use_llm': use_llm}
|
||||||
|
|
||||||
safe_id = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') or 'preset'
|
safe_id = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') or 'preset'
|
||||||
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
|
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
|
||||||
base_id = safe_id
|
base_id = safe_id
|
||||||
@@ -265,7 +271,7 @@ def register_routes(app):
|
|||||||
system_prompt = load_prompt('preset_system.txt')
|
system_prompt = load_prompt('preset_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
flash('Preset system prompt file not found.', 'error')
|
flash('Preset system prompt file not found.', 'error')
|
||||||
return redirect(request.url)
|
return render_template('presets/create.html', form_data=form_data)
|
||||||
try:
|
try:
|
||||||
llm_response = call_llm(
|
llm_response = call_llm(
|
||||||
f"Create a preset profile named '{name}' based on this description: {description}",
|
f"Create a preset profile named '{name}' based on this description: {description}",
|
||||||
@@ -276,7 +282,7 @@ def register_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("LLM error creating preset: %s", e)
|
logger.exception("LLM error creating preset: %s", e)
|
||||||
flash(f"AI generation failed: {e}", 'error')
|
flash(f"AI generation failed: {e}", 'error')
|
||||||
return redirect(request.url)
|
return render_template('presets/create.html', form_data=form_data)
|
||||||
else:
|
else:
|
||||||
preset_data = {
|
preset_data = {
|
||||||
'character': {'character_id': 'random', 'use_lora': True,
|
'character': {'character_id': 'random', 'use_lora': True,
|
||||||
@@ -314,7 +320,7 @@ def register_routes(app):
|
|||||||
flash(f"Preset '{name}' created!")
|
flash(f"Preset '{name}' created!")
|
||||||
return redirect(url_for('edit_preset', slug=safe_slug))
|
return redirect(url_for('edit_preset', slug=safe_slug))
|
||||||
|
|
||||||
return render_template('presets/create.html')
|
return render_template('presets/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/get_missing_presets')
|
@app.route('/get_missing_presets')
|
||||||
def get_missing_presets():
|
def get_missing_presets():
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from services.job_queue import (
|
from services.job_queue import (
|
||||||
_job_queue_lock, _job_queue, _job_history, _queue_worker_event,
|
_job_queue_lock, _job_queue, _llm_queue, _job_history,
|
||||||
|
_queue_worker_event, _llm_worker_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
# Both queues for iteration
|
||||||
|
_ALL_QUEUES = (_job_queue, _llm_queue)
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
|
||||||
@@ -12,23 +16,27 @@ def register_routes(app):
|
|||||||
def api_queue_list():
|
def api_queue_list():
|
||||||
"""Return the current queue as JSON."""
|
"""Return the current queue as JSON."""
|
||||||
with _job_queue_lock:
|
with _job_queue_lock:
|
||||||
jobs = [
|
jobs = []
|
||||||
{
|
for q in _ALL_QUEUES:
|
||||||
|
for j in q:
|
||||||
|
jobs.append({
|
||||||
'id': j['id'],
|
'id': j['id'],
|
||||||
'label': j['label'],
|
'label': j['label'],
|
||||||
'status': j['status'],
|
'status': j['status'],
|
||||||
'error': j['error'],
|
'error': j['error'],
|
||||||
'created_at': j['created_at'],
|
'created_at': j['created_at'],
|
||||||
}
|
'job_type': j.get('job_type', 'comfyui'),
|
||||||
for j in _job_queue
|
})
|
||||||
]
|
|
||||||
return {'jobs': jobs, 'count': len(jobs)}
|
return {'jobs': jobs, 'count': len(jobs)}
|
||||||
|
|
||||||
@app.route('/api/queue/count')
|
@app.route('/api/queue/count')
|
||||||
def api_queue_count():
|
def api_queue_count():
|
||||||
"""Return just the count of active (non-done, non-failed) jobs."""
|
"""Return just the count of active (non-done, non-failed) jobs."""
|
||||||
with _job_queue_lock:
|
with _job_queue_lock:
|
||||||
count = sum(1 for j in _job_queue if j['status'] in ('pending', 'processing', 'paused'))
|
count = sum(
|
||||||
|
1 for q in _ALL_QUEUES for j in q
|
||||||
|
if j['status'] in ('pending', 'processing', 'paused')
|
||||||
|
)
|
||||||
return {'count': count}
|
return {'count': count}
|
||||||
|
|
||||||
@app.route('/api/queue/<job_id>/remove', methods=['POST'])
|
@app.route('/api/queue/<job_id>/remove', methods=['POST'])
|
||||||
@@ -40,10 +48,12 @@ def register_routes(app):
|
|||||||
return {'error': 'Job not found'}, 404
|
return {'error': 'Job not found'}, 404
|
||||||
if job['status'] == 'processing':
|
if job['status'] == 'processing':
|
||||||
return {'error': 'Cannot remove a job that is currently processing'}, 400
|
return {'error': 'Cannot remove a job that is currently processing'}, 400
|
||||||
|
for q in _ALL_QUEUES:
|
||||||
try:
|
try:
|
||||||
_job_queue.remove(job)
|
q.remove(job)
|
||||||
|
break
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # Already not in queue
|
continue
|
||||||
job['status'] = 'removed'
|
job['status'] = 'removed'
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
@@ -58,6 +68,10 @@ def register_routes(app):
|
|||||||
job['status'] = 'paused'
|
job['status'] = 'paused'
|
||||||
elif job['status'] == 'paused':
|
elif job['status'] == 'paused':
|
||||||
job['status'] = 'pending'
|
job['status'] = 'pending'
|
||||||
|
# Signal the appropriate worker
|
||||||
|
if job.get('job_type') == 'llm':
|
||||||
|
_llm_worker_event.set()
|
||||||
|
else:
|
||||||
_queue_worker_event.set()
|
_queue_worker_event.set()
|
||||||
else:
|
else:
|
||||||
return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400
|
return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400
|
||||||
@@ -65,13 +79,14 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/api/queue/clear', methods=['POST'])
|
@app.route('/api/queue/clear', methods=['POST'])
|
||||||
def api_queue_clear():
|
def api_queue_clear():
|
||||||
"""Clear all pending jobs from the queue (allows current processing job to finish)."""
|
"""Clear all pending jobs from the queue (allows current processing jobs to finish)."""
|
||||||
removed_count = 0
|
removed_count = 0
|
||||||
with _job_queue_lock:
|
with _job_queue_lock:
|
||||||
pending_jobs = [j for j in _job_queue if j['status'] == 'pending']
|
for q in _ALL_QUEUES:
|
||||||
|
pending_jobs = [j for j in q if j['status'] == 'pending']
|
||||||
for job in pending_jobs:
|
for job in pending_jobs:
|
||||||
try:
|
try:
|
||||||
_job_queue.remove(job)
|
q.remove(job)
|
||||||
job['status'] = 'removed'
|
job['status'] = 'removed'
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -91,7 +106,8 @@ def register_routes(app):
|
|||||||
'label': job['label'],
|
'label': job['label'],
|
||||||
'status': job['status'],
|
'status': job['status'],
|
||||||
'error': job['error'],
|
'error': job['error'],
|
||||||
'comfy_prompt_id': job['comfy_prompt_id'],
|
'job_type': job.get('job_type', 'comfyui'),
|
||||||
|
'comfy_prompt_id': job.get('comfy_prompt_id'),
|
||||||
}
|
}
|
||||||
if job.get('result'):
|
if job.get('result'):
|
||||||
resp['result'] = job['result']
|
resp['result'] = job['result']
|
||||||
|
|||||||
202
routes/regenerate.py
Normal file
202
routes/regenerate.py
Normal 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}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
|
|
||||||
from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look
|
from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||||
from services.sync import sync_scenes
|
from services.sync import sync_scenes
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
@@ -37,8 +36,17 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/scenes')
|
@app.route('/scenes')
|
||||||
def scenes_index():
|
def scenes_index():
|
||||||
scenes = Scene.query.order_by(Scene.name).all()
|
query = Scene.query
|
||||||
return render_template('scenes/index.html', scenes=scenes)
|
fav = request.args.get('favourite')
|
||||||
|
nsfw = request.args.get('nsfw', 'all')
|
||||||
|
if fav == 'on':
|
||||||
|
query = query.filter_by(is_favourite=True)
|
||||||
|
if nsfw == 'sfw':
|
||||||
|
query = query.filter_by(is_nsfw=False)
|
||||||
|
elif nsfw == 'nsfw':
|
||||||
|
query = query.filter_by(is_nsfw=True)
|
||||||
|
scenes = query.order_by(Scene.is_favourite.desc(), Scene.name).all()
|
||||||
|
return render_template('scenes/index.html', scenes=scenes, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||||
|
|
||||||
@app.route('/scenes/rescan', methods=['POST'])
|
@app.route('/scenes/rescan', methods=['POST'])
|
||||||
def rescan_scenes():
|
def rescan_scenes():
|
||||||
@@ -117,9 +125,12 @@ def register_routes(app):
|
|||||||
else:
|
else:
|
||||||
new_data.setdefault('lora', {}).pop(bound, None)
|
new_data.setdefault('lora', {}).pop(bound, None)
|
||||||
|
|
||||||
# Update Tags (comma separated string to list)
|
# Update Tags (structured dict)
|
||||||
tags_raw = request.form.get('tags', '')
|
new_data['tags'] = {
|
||||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
'scene_type': request.form.get('tag_scene_type', '').strip(),
|
||||||
|
'nsfw': 'tag_nsfw' in request.form,
|
||||||
|
}
|
||||||
|
scene.is_nsfw = new_data['tags']['nsfw']
|
||||||
|
|
||||||
scene.data = new_data
|
scene.data = new_data
|
||||||
flag_modified(scene, "data")
|
flag_modified(scene, "data")
|
||||||
@@ -332,15 +343,16 @@ def register_routes(app):
|
|||||||
return redirect(url_for('scenes_index'))
|
return redirect(url_for('scenes_index'))
|
||||||
|
|
||||||
overwrite = request.form.get('overwrite') == 'true'
|
overwrite = request.form.get('overwrite') == 'true'
|
||||||
created_count = 0
|
skipped = 0
|
||||||
skipped_count = 0
|
job_ids = []
|
||||||
overwritten_count = 0
|
|
||||||
|
|
||||||
system_prompt = load_prompt('scene_system.txt')
|
system_prompt = load_prompt('scene_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
flash('Scene system prompt file not found.', 'error')
|
flash('Scene system prompt file not found.', 'error')
|
||||||
return redirect(url_for('scenes_index'))
|
return redirect(url_for('scenes_index'))
|
||||||
|
|
||||||
|
scenes_dir = app.config['SCENES_DIR']
|
||||||
|
|
||||||
for filename in os.listdir(backgrounds_lora_dir):
|
for filename in os.listdir(backgrounds_lora_dir):
|
||||||
if filename.endswith('.safetensors'):
|
if filename.endswith('.safetensors'):
|
||||||
name_base = filename.rsplit('.', 1)[0]
|
name_base = filename.rsplit('.', 1)[0]
|
||||||
@@ -348,11 +360,11 @@ def register_routes(app):
|
|||||||
scene_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
scene_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||||
|
|
||||||
json_filename = f"{scene_id}.json"
|
json_filename = f"{scene_id}.json"
|
||||||
json_path = os.path.join(app.config['SCENES_DIR'], json_filename)
|
json_path = os.path.join(scenes_dir, json_filename)
|
||||||
|
|
||||||
is_existing = os.path.exists(json_path)
|
is_existing = os.path.exists(json_path)
|
||||||
if is_existing and not overwrite:
|
if is_existing and not overwrite:
|
||||||
skipped_count += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
html_filename = f"{name_base}.html"
|
html_filename = f"{name_base}.html"
|
||||||
@@ -362,28 +374,24 @@ def register_routes(app):
|
|||||||
try:
|
try:
|
||||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||||
html_raw = hf.read()
|
html_raw = hf.read()
|
||||||
# Strip HTML tags but keep text content for LLM context
|
|
||||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||||
html_content = ' '.join(clean_html.split())
|
html_content = ' '.join(clean_html.split())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading HTML {html_filename}: {e}")
|
logger.error("Error reading HTML %s: %s", html_filename, e)
|
||||||
|
|
||||||
try:
|
def make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||||
print(f"Asking LLM to describe scene: {scene_name}")
|
def task_fn(job):
|
||||||
prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'"
|
prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'"
|
||||||
if html_content:
|
if html_content:
|
||||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||||
|
|
||||||
llm_response = call_llm(prompt, system_prompt)
|
llm_response = call_llm(prompt, system_prompt)
|
||||||
|
|
||||||
# Clean response
|
|
||||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||||
scene_data = json.loads(clean_json)
|
scene_data = json.loads(clean_json)
|
||||||
|
|
||||||
# Enforce system values while preserving LLM-extracted metadata
|
|
||||||
scene_data['scene_id'] = scene_id
|
scene_data['scene_id'] = scene_id
|
||||||
scene_data['scene_name'] = scene_name
|
scene_data['scene_name'] = scene_name
|
||||||
|
|
||||||
@@ -402,34 +410,33 @@ def register_routes(app):
|
|||||||
with open(json_path, 'w') as f:
|
with open(json_path, 'w') as f:
|
||||||
json.dump(scene_data, f, indent=2)
|
json.dump(scene_data, f, indent=2)
|
||||||
|
|
||||||
if is_existing:
|
job['result'] = {'name': scene_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||||
overwritten_count += 1
|
return task_fn
|
||||||
else:
|
|
||||||
created_count += 1
|
|
||||||
|
|
||||||
# Small delay to avoid API rate limits if many files
|
job = _enqueue_task(f"Create scene: {scene_name}", make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||||
time.sleep(0.5)
|
job_ids.append(job['id'])
|
||||||
|
|
||||||
except Exception as e:
|
if job_ids:
|
||||||
print(f"Error creating scene for {filename}: {e}")
|
def sync_task(job):
|
||||||
|
|
||||||
if created_count > 0 or overwritten_count > 0:
|
|
||||||
sync_scenes()
|
sync_scenes()
|
||||||
msg = f'Successfully processed scenes: {created_count} created, {overwritten_count} overwritten.'
|
job['result'] = {'synced': True}
|
||||||
if skipped_count > 0:
|
_enqueue_task("Sync scenes DB", sync_task)
|
||||||
msg += f' (Skipped {skipped_count} existing)'
|
|
||||||
flash(msg)
|
|
||||||
else:
|
|
||||||
flash(f'No scenes created or overwritten. {skipped_count} existing scenes found.')
|
|
||||||
|
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||||
|
flash(f'Queued {len(job_ids)} scene tasks ({skipped} skipped).')
|
||||||
return redirect(url_for('scenes_index'))
|
return redirect(url_for('scenes_index'))
|
||||||
|
|
||||||
@app.route('/scene/create', methods=['GET', 'POST'])
|
@app.route('/scene/create', methods=['GET', 'POST'])
|
||||||
def create_scene():
|
def create_scene():
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name')
|
name = request.form.get('name')
|
||||||
slug = request.form.get('filename', '').strip()
|
slug = request.form.get('filename', '').strip()
|
||||||
|
|
||||||
|
form_data = {'name': name, 'filename': slug}
|
||||||
|
|
||||||
if not slug:
|
if not slug:
|
||||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||||
|
|
||||||
@@ -478,9 +485,9 @@ def register_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
print(f"Save error: {e}")
|
||||||
flash(f"Failed to create scene: {e}")
|
flash(f"Failed to create scene: {e}")
|
||||||
return redirect(request.url)
|
return render_template('scenes/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('scenes/create.html')
|
return render_template('scenes/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/scene/<path:slug>/clone', methods=['POST'])
|
@app.route('/scene/<path:slug>/clone', methods=['POST'])
|
||||||
def clone_scene(slug):
|
def clone_scene(slug):
|
||||||
@@ -538,3 +545,12 @@ def register_routes(app):
|
|||||||
with open(file_path, 'w') as f:
|
with open(file_path, 'w') as f:
|
||||||
json.dump(new_data, f, indent=2)
|
json.dump(new_data, f, indent=2)
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
|
|
||||||
|
@app.route('/scene/<path:slug>/favourite', methods=['POST'])
|
||||||
|
def toggle_scene_favourite(slug):
|
||||||
|
scene = Scene.query.filter_by(slug=slug).first_or_404()
|
||||||
|
scene.is_favourite = not scene.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': scene.is_favourite}
|
||||||
|
return redirect(url_for('scene_detail', slug=slug))
|
||||||
|
|||||||
209
routes/search.py
Normal file
209
routes/search.py
Normal 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),
|
||||||
|
)
|
||||||
@@ -70,7 +70,6 @@ def register_routes(app):
|
|||||||
if category == 'outfits':
|
if category == 'outfits':
|
||||||
wardrobe = entity.data.get('wardrobe', {})
|
wardrobe = entity.data.get('wardrobe', {})
|
||||||
outfit_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
outfit_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||||
tags = entity.data.get('tags', [])
|
|
||||||
wardrobe_parts = [v for v in wardrobe.values() if isinstance(v, str) and v]
|
wardrobe_parts = [v for v in wardrobe.values() if isinstance(v, str) and v]
|
||||||
char_parts = []
|
char_parts = []
|
||||||
face_parts = []
|
face_parts = []
|
||||||
@@ -83,7 +82,7 @@ def register_routes(app):
|
|||||||
face_parts = [v for v in [identity.get('head'),
|
face_parts = [v for v in [identity.get('head'),
|
||||||
defaults.get('expression')] if v]
|
defaults.get('expression')] if v]
|
||||||
hand_parts = [v for v in [wardrobe.get('hands')] if v]
|
hand_parts = [v for v in [wardrobe.get('hands')] if v]
|
||||||
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags
|
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts
|
||||||
return {
|
return {
|
||||||
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
||||||
'face': _dedup_tags(', '.join(face_parts)),
|
'face': _dedup_tags(', '.join(face_parts)),
|
||||||
@@ -93,7 +92,6 @@ def register_routes(app):
|
|||||||
if category == 'actions':
|
if category == 'actions':
|
||||||
action_data = entity.data.get('action', {})
|
action_data = entity.data.get('action', {})
|
||||||
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||||
tags = entity.data.get('tags', [])
|
|
||||||
from utils import _BODY_GROUP_KEYS
|
from utils import _BODY_GROUP_KEYS
|
||||||
pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
|
pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
|
||||||
expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
|
expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
|
||||||
@@ -104,7 +102,7 @@ def register_routes(app):
|
|||||||
identity = character.data.get('identity', {})
|
identity = character.data.get('identity', {})
|
||||||
char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
|
char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
|
||||||
face_parts = [v for v in [identity.get('head')] + expr_parts if v]
|
face_parts = [v for v in [identity.get('head')] + expr_parts if v]
|
||||||
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
|
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts
|
||||||
return {
|
return {
|
||||||
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
||||||
'face': _dedup_tags(', '.join(face_parts)),
|
'face': _dedup_tags(', '.join(face_parts)),
|
||||||
@@ -113,20 +111,19 @@ def register_routes(app):
|
|||||||
|
|
||||||
# styles / scenes / detailers
|
# styles / scenes / detailers
|
||||||
entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||||
tags = entity.data.get('tags', [])
|
|
||||||
|
|
||||||
if category == 'styles':
|
if category == 'styles':
|
||||||
sdata = entity.data.get('style', {})
|
sdata = entity.data.get('style', {})
|
||||||
artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else ''
|
artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else ''
|
||||||
style_tags = sdata.get('artistic_style', '')
|
style_tags = sdata.get('artistic_style', '')
|
||||||
entity_parts = [p for p in [entity_triggers, artist, style_tags] + tags if p]
|
entity_parts = [p for p in [entity_triggers, artist, style_tags] if p]
|
||||||
elif category == 'scenes':
|
elif category == 'scenes':
|
||||||
sdata = entity.data.get('scene', {})
|
sdata = entity.data.get('scene', {})
|
||||||
scene_parts = [v for v in sdata.values() if isinstance(v, str) and v]
|
scene_parts = [v for v in sdata.values() if isinstance(v, str) and v]
|
||||||
entity_parts = [p for p in [entity_triggers] + scene_parts + tags if p]
|
entity_parts = [p for p in [entity_triggers] + scene_parts if p]
|
||||||
else: # detailers
|
else: # detailers
|
||||||
det_prompt = entity.data.get('prompt', '')
|
det_prompt = entity.data.get('prompt', '')
|
||||||
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p]
|
entity_parts = [p for p in [entity_triggers, det_prompt] if p]
|
||||||
|
|
||||||
char_data_no_lora = _get_character_data_without_lora(character)
|
char_data_no_lora = _get_character_data_without_lora(character)
|
||||||
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''}
|
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''}
|
||||||
|
|||||||
123
routes/styles.py
123
routes/styles.py
@@ -2,7 +2,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import random
|
import random
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||||
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
|
|
||||||
from models import db, Character, Style, Detailer, Settings
|
from models import db, Character, Style, Detailer, Settings
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||||
from services.sync import sync_styles
|
from services.sync import sync_styles
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
@@ -82,8 +81,17 @@ def register_routes(app):
|
|||||||
|
|
||||||
@app.route('/styles')
|
@app.route('/styles')
|
||||||
def styles_index():
|
def styles_index():
|
||||||
styles = Style.query.order_by(Style.name).all()
|
query = Style.query
|
||||||
return render_template('styles/index.html', styles=styles)
|
fav = request.args.get('favourite')
|
||||||
|
nsfw = request.args.get('nsfw', 'all')
|
||||||
|
if fav == 'on':
|
||||||
|
query = query.filter_by(is_favourite=True)
|
||||||
|
if nsfw == 'sfw':
|
||||||
|
query = query.filter_by(is_nsfw=False)
|
||||||
|
elif nsfw == 'nsfw':
|
||||||
|
query = query.filter_by(is_nsfw=True)
|
||||||
|
styles = query.order_by(Style.is_favourite.desc(), Style.name).all()
|
||||||
|
return render_template('styles/index.html', styles=styles, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||||
|
|
||||||
@app.route('/styles/rescan', methods=['POST'])
|
@app.route('/styles/rescan', methods=['POST'])
|
||||||
def rescan_styles():
|
def rescan_styles():
|
||||||
@@ -158,6 +166,13 @@ def register_routes(app):
|
|||||||
else:
|
else:
|
||||||
new_data.setdefault('lora', {}).pop(bound, None)
|
new_data.setdefault('lora', {}).pop(bound, None)
|
||||||
|
|
||||||
|
# Update Tags (structured dict)
|
||||||
|
new_data['tags'] = {
|
||||||
|
'style_type': request.form.get('tag_style_type', '').strip(),
|
||||||
|
'nsfw': 'tag_nsfw' in request.form,
|
||||||
|
}
|
||||||
|
style.is_nsfw = new_data['tags']['nsfw']
|
||||||
|
|
||||||
style.data = new_data
|
style.data = new_data
|
||||||
flag_modified(style, "data")
|
flag_modified(style, "data")
|
||||||
|
|
||||||
@@ -343,21 +358,27 @@ def register_routes(app):
|
|||||||
styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/')
|
styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/')
|
||||||
_lora_subfolder = os.path.basename(styles_lora_dir)
|
_lora_subfolder = os.path.basename(styles_lora_dir)
|
||||||
if not os.path.exists(styles_lora_dir):
|
if not os.path.exists(styles_lora_dir):
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'error': 'Styles LoRA directory not found.'}, 400
|
||||||
flash('Styles LoRA directory not found.', 'error')
|
flash('Styles LoRA directory not found.', 'error')
|
||||||
return redirect(url_for('styles_index'))
|
return redirect(url_for('styles_index'))
|
||||||
|
|
||||||
overwrite = request.form.get('overwrite') == 'true'
|
overwrite = request.form.get('overwrite') == 'true'
|
||||||
created_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
overwritten_count = 0
|
|
||||||
|
|
||||||
system_prompt = load_prompt('style_system.txt')
|
system_prompt = load_prompt('style_system.txt')
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'error': 'Style system prompt file not found.'}, 500
|
||||||
flash('Style system prompt file not found.', 'error')
|
flash('Style system prompt file not found.', 'error')
|
||||||
return redirect(url_for('styles_index'))
|
return redirect(url_for('styles_index'))
|
||||||
|
|
||||||
for filename in os.listdir(styles_lora_dir):
|
job_ids = []
|
||||||
if filename.endswith('.safetensors'):
|
skipped = 0
|
||||||
|
|
||||||
|
for filename in sorted(os.listdir(styles_lora_dir)):
|
||||||
|
if not filename.endswith('.safetensors'):
|
||||||
|
continue
|
||||||
|
|
||||||
name_base = filename.rsplit('.', 1)[0]
|
name_base = filename.rsplit('.', 1)[0]
|
||||||
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
||||||
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||||
@@ -367,11 +388,11 @@ def register_routes(app):
|
|||||||
|
|
||||||
is_existing = os.path.exists(json_path)
|
is_existing = os.path.exists(json_path)
|
||||||
if is_existing and not overwrite:
|
if is_existing and not overwrite:
|
||||||
skipped_count += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
html_filename = f"{name_base}.html"
|
# Read HTML companion file if it exists
|
||||||
html_path = os.path.join(styles_lora_dir, html_filename)
|
html_path = os.path.join(styles_lora_dir, f"{name_base}.html")
|
||||||
html_content = ""
|
html_content = ""
|
||||||
if os.path.exists(html_path):
|
if os.path.exists(html_path):
|
||||||
try:
|
try:
|
||||||
@@ -382,27 +403,28 @@ def register_routes(app):
|
|||||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||||
html_content = ' '.join(clean_html.split())
|
html_content = ' '.join(clean_html.split())
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f"Error reading HTML {html_filename}: {e}")
|
pass
|
||||||
|
|
||||||
try:
|
def make_task(fn, sid, sname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||||
print(f"Asking LLM to describe style: {style_name}")
|
def task_fn(job):
|
||||||
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{filename}'"
|
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{fn}'"
|
||||||
if html_content:
|
if html_ctx:
|
||||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
|
||||||
|
|
||||||
llm_response = call_llm(prompt, system_prompt)
|
llm_response = call_llm(prompt, sys_prompt)
|
||||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||||
style_data = json.loads(clean_json)
|
style_data = json.loads(clean_json)
|
||||||
|
|
||||||
style_data['style_id'] = style_id
|
style_data['style_id'] = sid
|
||||||
style_data['style_name'] = style_name
|
style_data['style_name'] = sname
|
||||||
|
|
||||||
if 'lora' not in style_data: style_data['lora'] = {}
|
if 'lora' not in style_data:
|
||||||
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
style_data['lora'] = {}
|
||||||
|
style_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||||
|
|
||||||
if not style_data['lora'].get('lora_triggers'):
|
if not style_data['lora'].get('lora_triggers'):
|
||||||
style_data['lora']['lora_triggers'] = name_base
|
style_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||||
if style_data['lora'].get('lora_weight') is None:
|
if style_data['lora'].get('lora_weight') is None:
|
||||||
style_data['lora']['lora_weight'] = 1.0
|
style_data['lora']['lora_weight'] = 1.0
|
||||||
if style_data['lora'].get('lora_weight_min') is None:
|
if style_data['lora'].get('lora_weight_min') is None:
|
||||||
@@ -410,35 +432,43 @@ def register_routes(app):
|
|||||||
if style_data['lora'].get('lora_weight_max') is None:
|
if style_data['lora'].get('lora_weight_max') is None:
|
||||||
style_data['lora']['lora_weight_max'] = 1.0
|
style_data['lora']['lora_weight_max'] = 1.0
|
||||||
|
|
||||||
with open(json_path, 'w') as f:
|
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||||
|
with open(jp, 'w') as f:
|
||||||
json.dump(style_data, f, indent=2)
|
json.dump(style_data, f, indent=2)
|
||||||
|
|
||||||
if is_existing:
|
job['result'] = {'name': sname, 'action': 'overwritten' if is_exist else 'created'}
|
||||||
overwritten_count += 1
|
return task_fn
|
||||||
else:
|
|
||||||
created_count += 1
|
|
||||||
|
|
||||||
time.sleep(0.5)
|
job = _enqueue_task(
|
||||||
except Exception as e:
|
f"Create style: {style_name}",
|
||||||
print(f"Error creating style for {filename}: {e}")
|
make_task(filename, style_id, style_name, json_path,
|
||||||
|
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||||
|
)
|
||||||
|
job_ids.append(job['id'])
|
||||||
|
|
||||||
if created_count > 0 or overwritten_count > 0:
|
# Enqueue a sync task to run after all creates
|
||||||
|
if job_ids:
|
||||||
|
def sync_task(job):
|
||||||
sync_styles()
|
sync_styles()
|
||||||
msg = f'Successfully processed styles: {created_count} created, {overwritten_count} overwritten.'
|
job['result'] = {'synced': True}
|
||||||
if skipped_count > 0:
|
_enqueue_task("Sync styles DB", sync_task)
|
||||||
msg += f' (Skipped {skipped_count} existing)'
|
|
||||||
flash(msg)
|
|
||||||
else:
|
|
||||||
flash(f'No styles created or overwritten. {skipped_count} existing styles found.')
|
|
||||||
|
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||||
|
|
||||||
|
flash(f'Queued {len(job_ids)} style creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||||
return redirect(url_for('styles_index'))
|
return redirect(url_for('styles_index'))
|
||||||
|
|
||||||
@app.route('/style/create', methods=['GET', 'POST'])
|
@app.route('/style/create', methods=['GET', 'POST'])
|
||||||
def create_style():
|
def create_style():
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name')
|
name = request.form.get('name')
|
||||||
slug = request.form.get('filename', '').strip()
|
slug = request.form.get('filename', '').strip()
|
||||||
|
|
||||||
|
form_data = {'name': name, 'filename': slug}
|
||||||
|
|
||||||
if not slug:
|
if not slug:
|
||||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||||
|
|
||||||
@@ -483,9 +513,9 @@ def register_routes(app):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
print(f"Save error: {e}")
|
||||||
flash(f"Failed to create style: {e}")
|
flash(f"Failed to create style: {e}")
|
||||||
return redirect(request.url)
|
return render_template('styles/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('styles/create.html')
|
return render_template('styles/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/style/<path:slug>/clone', methods=['POST'])
|
@app.route('/style/<path:slug>/clone', methods=['POST'])
|
||||||
def clone_style(slug):
|
def clone_style(slug):
|
||||||
@@ -542,3 +572,12 @@ def register_routes(app):
|
|||||||
with open(file_path, 'w') as f:
|
with open(file_path, 'w') as f:
|
||||||
json.dump(new_data, f, indent=2)
|
json.dump(new_data, f, indent=2)
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
|
|
||||||
|
@app.route('/style/<path:slug>/favourite', methods=['POST'])
|
||||||
|
def toggle_style_favourite(slug):
|
||||||
|
style_obj = Style.query.filter_by(slug=slug).first_or_404()
|
||||||
|
style_obj.is_favourite = not style_obj.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': style_obj.is_favourite}
|
||||||
|
return redirect(url_for('style_detail', slug=slug))
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ from services.sync import _resolve_preset_entity, _resolve_preset_fields
|
|||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def generate_from_preset(preset, overrides=None):
|
def generate_from_preset(preset, overrides=None, save_category='presets'):
|
||||||
"""Execute preset-based generation.
|
"""Execute preset-based generation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
preset: Preset ORM object
|
preset: Preset ORM object
|
||||||
overrides: optional dict with keys:
|
overrides: optional dict with keys:
|
||||||
checkpoint, extra_positive, extra_negative, seed, width, height, action
|
checkpoint, extra_positive, extra_negative, seed, width, height, action
|
||||||
|
save_category: upload sub-directory ('presets' or 'generator')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
job dict from _enqueue_job()
|
job dict from _enqueue_job()
|
||||||
@@ -52,6 +53,19 @@ def generate_from_preset(preset, overrides=None):
|
|||||||
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
|
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
|
||||||
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
|
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
|
||||||
|
|
||||||
|
# Build sidecar metadata with resolved entity slugs
|
||||||
|
resolved_meta = {
|
||||||
|
'preset_slug': preset.slug,
|
||||||
|
'preset_name': preset.name,
|
||||||
|
'character_slug': character.slug if character else None,
|
||||||
|
'outfit_slug': outfit.slug if outfit else None,
|
||||||
|
'action_slug': action_obj.slug if action_obj else None,
|
||||||
|
'style_slug': style_obj.slug if style_obj else None,
|
||||||
|
'scene_slug': scene_obj.slug if scene_obj else None,
|
||||||
|
'detailer_slug': detailer_obj.slug if detailer_obj else None,
|
||||||
|
'look_slug': look_obj.slug if look_obj else None,
|
||||||
|
}
|
||||||
|
|
||||||
# Checkpoint: override > preset config > default
|
# Checkpoint: override > preset config > default
|
||||||
checkpoint_override = overrides.get('checkpoint', '').strip() if overrides.get('checkpoint') else ''
|
checkpoint_override = overrides.get('checkpoint', '').strip() if overrides.get('checkpoint') else ''
|
||||||
if checkpoint_override:
|
if checkpoint_override:
|
||||||
@@ -71,14 +85,31 @@ def generate_from_preset(preset, overrides=None):
|
|||||||
else:
|
else:
|
||||||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||||||
|
|
||||||
|
resolved_meta['checkpoint_path'] = ckpt_path
|
||||||
|
|
||||||
# Resolve selected fields from preset toggles
|
# Resolve selected fields from preset toggles
|
||||||
selected_fields = _resolve_preset_fields(data)
|
selected_fields = _resolve_preset_fields(data)
|
||||||
|
|
||||||
|
# Check suppress_wardrobe: preset override > action default
|
||||||
|
suppress_wardrobe = False
|
||||||
|
preset_suppress = action_cfg.get('suppress_wardrobe')
|
||||||
|
if preset_suppress == 'random':
|
||||||
|
suppress_wardrobe = random.choice([True, False])
|
||||||
|
elif preset_suppress is not None:
|
||||||
|
suppress_wardrobe = bool(preset_suppress)
|
||||||
|
elif action_obj:
|
||||||
|
suppress_wardrobe = action_obj.data.get('suppress_wardrobe', False)
|
||||||
|
|
||||||
|
if suppress_wardrobe:
|
||||||
|
selected_fields = [f for f in selected_fields if not f.startswith('wardrobe::')]
|
||||||
|
|
||||||
# Build combined data for prompt building
|
# Build combined data for prompt building
|
||||||
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
|
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
|
||||||
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
|
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
|
||||||
if wardrobe_source is None:
|
if wardrobe_source is None:
|
||||||
wardrobe_source = character.get_active_wardrobe() if character else {}
|
wardrobe_source = character.get_active_wardrobe() if character else {}
|
||||||
|
if suppress_wardrobe:
|
||||||
|
wardrobe_source = {}
|
||||||
|
|
||||||
combined_data = {
|
combined_data = {
|
||||||
'character_id': character.character_id if character else 'unknown',
|
'character_id': character.character_id if character else 'unknown',
|
||||||
@@ -88,7 +119,6 @@ def generate_from_preset(preset, overrides=None):
|
|||||||
'styles': character.data.get('styles', {}) if character else {},
|
'styles': character.data.get('styles', {}) if character else {},
|
||||||
'lora': (look_obj.data.get('lora', {}) if look_obj
|
'lora': (look_obj.data.get('lora', {}) if look_obj
|
||||||
else (character.data.get('lora', {}) if character else {})),
|
else (character.data.get('lora', {}) if character else {})),
|
||||||
'tags': (character.data.get('tags', []) if character else []) + data.get('tags', []),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build extras prompt from secondary resources
|
# Build extras prompt from secondary resources
|
||||||
@@ -108,7 +138,6 @@ def generate_from_preset(preset, overrides=None):
|
|||||||
trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
|
trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||||
if trg:
|
if trg:
|
||||||
extras_parts.append(trg)
|
extras_parts.append(trg)
|
||||||
extras_parts.extend(action_obj.data.get('tags', []))
|
|
||||||
if style_obj:
|
if style_obj:
|
||||||
s = style_obj.data.get('style', {})
|
s = style_obj.data.get('style', {})
|
||||||
if s.get('artist_name'):
|
if s.get('artist_name'):
|
||||||
@@ -133,7 +162,6 @@ def generate_from_preset(preset, overrides=None):
|
|||||||
trg = scene_obj.data.get('lora', {}).get('lora_triggers', '')
|
trg = scene_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||||
if trg:
|
if trg:
|
||||||
extras_parts.append(trg)
|
extras_parts.append(trg)
|
||||||
extras_parts.extend(scene_obj.data.get('tags', []))
|
|
||||||
if detailer_obj:
|
if detailer_obj:
|
||||||
prompt_val = detailer_obj.data.get('prompt', '')
|
prompt_val = detailer_obj.data.get('prompt', '')
|
||||||
if isinstance(prompt_val, list):
|
if isinstance(prompt_val, list):
|
||||||
@@ -195,6 +223,7 @@ def generate_from_preset(preset, overrides=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
label = f"Preset: {preset.name} – {action}"
|
label = f"Preset: {preset.name} – {action}"
|
||||||
job = _enqueue_job(label, workflow, _make_finalize('presets', preset.slug, Preset, action))
|
db_model = Preset if save_category == 'presets' else None
|
||||||
|
job = _enqueue_job(label, workflow, _make_finalize(save_category, preset.slug, db_model, action, metadata=resolved_meta))
|
||||||
|
|
||||||
return job
|
return job
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -27,8 +28,10 @@ logger = logging.getLogger('gaze')
|
|||||||
|
|
||||||
_job_queue_lock = threading.Lock()
|
_job_queue_lock = threading.Lock()
|
||||||
_job_queue = deque() # ordered list of job dicts (pending + paused + processing)
|
_job_queue = deque() # ordered list of job dicts (pending + paused + processing)
|
||||||
|
_llm_queue = deque() # ordered list of LLM task dicts (pending + paused + processing)
|
||||||
_job_history = {} # job_id -> job dict (all jobs ever added, for status lookup)
|
_job_history = {} # job_id -> job dict (all jobs ever added, for status lookup)
|
||||||
_queue_worker_event = threading.Event() # signals worker that a new job is available
|
_queue_worker_event = threading.Event() # signals worker that a new job is available
|
||||||
|
_llm_worker_event = threading.Event() # signals LLM worker that a new task is available
|
||||||
|
|
||||||
# Stored reference to the Flask app, set by init_queue_worker()
|
# Stored reference to the Flask app, set by init_queue_worker()
|
||||||
_app = None
|
_app = None
|
||||||
@@ -39,6 +42,7 @@ def _enqueue_job(label, workflow, finalize_fn):
|
|||||||
job = {
|
job = {
|
||||||
'id': str(uuid.uuid4()),
|
'id': str(uuid.uuid4()),
|
||||||
'label': label,
|
'label': label,
|
||||||
|
'job_type': 'comfyui',
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
'workflow': workflow,
|
'workflow': workflow,
|
||||||
'finalize_fn': finalize_fn,
|
'finalize_fn': finalize_fn,
|
||||||
@@ -55,6 +59,26 @@ def _enqueue_job(label, workflow, finalize_fn):
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _enqueue_task(label, task_fn):
|
||||||
|
"""Add a generic task job (e.g. LLM call) to the LLM queue. Returns the job dict."""
|
||||||
|
job = {
|
||||||
|
'id': str(uuid.uuid4()),
|
||||||
|
'label': label,
|
||||||
|
'job_type': 'llm',
|
||||||
|
'status': 'pending',
|
||||||
|
'task_fn': task_fn,
|
||||||
|
'error': None,
|
||||||
|
'result': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
with _job_queue_lock:
|
||||||
|
_llm_queue.append(job)
|
||||||
|
_job_history[job['id']] = job
|
||||||
|
logger.info("LLM task queued: [%s] %s", job['id'][:8], label)
|
||||||
|
_llm_worker_event.set()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
def _queue_worker():
|
def _queue_worker():
|
||||||
"""Background thread: processes jobs from _job_queue sequentially."""
|
"""Background thread: processes jobs from _job_queue sequentially."""
|
||||||
while True:
|
while True:
|
||||||
@@ -174,13 +198,14 @@ def _queue_worker():
|
|||||||
_prune_job_history()
|
_prune_job_history()
|
||||||
|
|
||||||
|
|
||||||
def _make_finalize(category, slug, db_model_class=None, action=None):
|
def _make_finalize(category, slug, db_model_class=None, action=None, metadata=None):
|
||||||
"""Return a finalize callback for a standard queue job.
|
"""Return a finalize callback for a standard queue job.
|
||||||
|
|
||||||
category — upload sub-directory name (e.g. 'characters', 'outfits')
|
category — upload sub-directory name (e.g. 'characters', 'outfits')
|
||||||
slug — entity slug used for the upload folder name
|
slug — entity slug used for the upload folder name
|
||||||
db_model_class — SQLAlchemy model class for cover-image DB update; None = skip
|
db_model_class — SQLAlchemy model class for cover-image DB update; None = skip
|
||||||
action — 'replace' → update DB; None → always update; anything else → skip
|
action — 'replace' → update DB; None → always update; anything else → skip
|
||||||
|
metadata — optional dict to write as JSON sidecar alongside the image
|
||||||
"""
|
"""
|
||||||
def _finalize(comfy_prompt_id, job):
|
def _finalize(comfy_prompt_id, job):
|
||||||
logger.debug("=" * 80)
|
logger.debug("=" * 80)
|
||||||
@@ -212,6 +237,14 @@ def _make_finalize(category, slug, db_model_class=None, action=None):
|
|||||||
f.write(image_data)
|
f.write(image_data)
|
||||||
logger.info("Image saved: %s (%d bytes)", full_path, len(image_data))
|
logger.info("Image saved: %s (%d bytes)", full_path, len(image_data))
|
||||||
|
|
||||||
|
# Write JSON sidecar with generation metadata (if provided)
|
||||||
|
if metadata is not None:
|
||||||
|
sidecar_name = filename.rsplit('.', 1)[0] + '.json'
|
||||||
|
sidecar_path = os.path.join(folder, sidecar_name)
|
||||||
|
with open(sidecar_path, 'w') as sf:
|
||||||
|
json.dump(metadata, sf)
|
||||||
|
logger.debug(" Sidecar written: %s", sidecar_path)
|
||||||
|
|
||||||
relative_path = f"{category}/{slug}/{filename}"
|
relative_path = f"{category}/{slug}/{filename}"
|
||||||
# Include the seed used for this generation
|
# Include the seed used for this generation
|
||||||
used_seed = job['workflow'].get('3', {}).get('inputs', {}).get('seed')
|
used_seed = job['workflow'].get('3', {}).get('inputs', {}).get('seed')
|
||||||
@@ -244,6 +277,51 @@ def _make_finalize(category, slug, db_model_class=None, action=None):
|
|||||||
return _finalize
|
return _finalize
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_queue_worker():
|
||||||
|
"""Background thread: processes LLM task jobs sequentially."""
|
||||||
|
while True:
|
||||||
|
_llm_worker_event.wait()
|
||||||
|
_llm_worker_event.clear()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
job = None
|
||||||
|
with _job_queue_lock:
|
||||||
|
for j in _llm_queue:
|
||||||
|
if j['status'] == 'pending':
|
||||||
|
job = j
|
||||||
|
break
|
||||||
|
|
||||||
|
if job is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
with _job_queue_lock:
|
||||||
|
job['status'] = 'processing'
|
||||||
|
|
||||||
|
logger.info("LLM task started: [%s] %s", job['id'][:8], job['label'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _app.app_context():
|
||||||
|
job['task_fn'](job)
|
||||||
|
|
||||||
|
with _job_queue_lock:
|
||||||
|
job['status'] = 'done'
|
||||||
|
logger.info("LLM task completed: [%s] %s", job['id'][:8], job['label'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("LLM task failed: [%s] %s — %s", job['id'][:8], job['label'], e)
|
||||||
|
with _job_queue_lock:
|
||||||
|
job['status'] = 'failed'
|
||||||
|
job['error'] = str(e)
|
||||||
|
|
||||||
|
with _job_queue_lock:
|
||||||
|
try:
|
||||||
|
_llm_queue.remove(job)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_prune_job_history()
|
||||||
|
|
||||||
|
|
||||||
def _prune_job_history(max_age_seconds=3600):
|
def _prune_job_history(max_age_seconds=3600):
|
||||||
"""Remove completed/failed jobs older than max_age_seconds from _job_history."""
|
"""Remove completed/failed jobs older than max_age_seconds from _job_history."""
|
||||||
cutoff = time.time() - max_age_seconds
|
cutoff = time.time() - max_age_seconds
|
||||||
@@ -261,5 +339,5 @@ def init_queue_worker(flask_app):
|
|||||||
"""
|
"""
|
||||||
global _app
|
global _app
|
||||||
_app = flask_app
|
_app = flask_app
|
||||||
worker = threading.Thread(target=_queue_worker, daemon=True, name='queue-worker')
|
threading.Thread(target=_queue_worker, daemon=True, name='comfyui-worker').start()
|
||||||
worker.start()
|
threading.Thread(target=_llm_queue_worker, daemon=True, name='llm-worker').start()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import requests
|
import requests
|
||||||
from flask import request as flask_request
|
from flask import has_request_context, request as flask_request
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from models import Settings
|
from models import Settings
|
||||||
@@ -77,6 +77,28 @@ def call_mcp_tool(name, arguments):
|
|||||||
return json.dumps({"error": str(e)})
|
return json.dumps({"error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_character_mcp_tool(name, arguments):
|
||||||
|
server_params = StdioServerParameters(
|
||||||
|
command="docker",
|
||||||
|
args=["run", "--rm", "-i",
|
||||||
|
"-v", "character-cache:/root/.local/share/character_details",
|
||||||
|
"character-mcp:latest"],
|
||||||
|
)
|
||||||
|
async with stdio_client(server_params) as (read, write):
|
||||||
|
async with ClientSession(read, write) as session:
|
||||||
|
await session.initialize()
|
||||||
|
result = await session.call_tool(name, arguments)
|
||||||
|
return result.content[0].text
|
||||||
|
|
||||||
|
|
||||||
|
def call_character_mcp_tool(name, arguments):
|
||||||
|
try:
|
||||||
|
return asyncio.run(_run_character_mcp_tool(name, arguments))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Character MCP Tool Error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def load_prompt(filename):
|
def load_prompt(filename):
|
||||||
path = os.path.join('data/prompts', filename)
|
path = os.path.join('data/prompts', filename)
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
@@ -100,7 +122,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"HTTP-Referer": flask_request.url_root,
|
"HTTP-Referer": flask_request.url_root if has_request_context() else "http://localhost:5000/",
|
||||||
"X-Title": "Character Browser"
|
"X-Title": "Character Browser"
|
||||||
}
|
}
|
||||||
model = settings.openrouter_model or 'google/gemini-2.0-flash-001'
|
model = settings.openrouter_model or 'google/gemini-2.0-flash-001'
|
||||||
@@ -120,7 +142,8 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
]
|
]
|
||||||
|
|
||||||
max_turns = 10
|
max_turns = 15
|
||||||
|
tool_turns_remaining = 8 # stop offering tools after this many tool-calling turns
|
||||||
use_tools = True
|
use_tools = True
|
||||||
format_retries = 3 # retries allowed for unexpected response format
|
format_retries = 3 # retries allowed for unexpected response format
|
||||||
|
|
||||||
@@ -131,13 +154,13 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
"messages": messages,
|
"messages": messages,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Only add tools if supported/requested
|
# Only add tools if supported/requested and we haven't exhausted tool turns
|
||||||
if use_tools:
|
if use_tools and tool_turns_remaining > 0:
|
||||||
data["tools"] = DANBOORU_TOOLS
|
data["tools"] = DANBOORU_TOOLS
|
||||||
data["tool_choice"] = "auto"
|
data["tool_choice"] = "auto"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(url, headers=headers, json=data)
|
response = requests.post(url, headers=headers, json=data, timeout=120)
|
||||||
|
|
||||||
# If 400 Bad Request and we were using tools, try once without tools
|
# If 400 Bad Request and we were using tools, try once without tools
|
||||||
if response.status_code == 400 and use_tools:
|
if response.status_code == 400 and use_tools:
|
||||||
@@ -158,6 +181,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
raise KeyError('message')
|
raise KeyError('message')
|
||||||
|
|
||||||
if message.get('tool_calls'):
|
if message.get('tool_calls'):
|
||||||
|
tool_turns_remaining -= 1
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
for tool_call in message['tool_calls']:
|
for tool_call in message['tool_calls']:
|
||||||
name = tool_call['function']['name']
|
name = tool_call['function']['name']
|
||||||
@@ -170,6 +194,8 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
"name": name,
|
"name": name,
|
||||||
"content": tool_result
|
"content": tool_result
|
||||||
})
|
})
|
||||||
|
if tool_turns_remaining <= 0:
|
||||||
|
print("Tool turn limit reached — next request will not offer tools")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return message['content']
|
return message['content']
|
||||||
|
|||||||
@@ -171,10 +171,6 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
|||||||
if style_data.get('artistic_style') and is_selected('style', 'artistic_style'):
|
if style_data.get('artistic_style') and is_selected('style', 'artistic_style'):
|
||||||
parts.append(style_data['artistic_style'])
|
parts.append(style_data['artistic_style'])
|
||||||
|
|
||||||
tags = data.get('tags', [])
|
|
||||||
if tags and is_selected('special', 'tags'):
|
|
||||||
parts.extend(tags)
|
|
||||||
|
|
||||||
lora = data.get('lora', {})
|
lora = data.get('lora', {})
|
||||||
if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'):
|
if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'):
|
||||||
parts.append(lora.get('lora_triggers'))
|
parts.append(lora.get('lora_triggers'))
|
||||||
@@ -283,7 +279,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
|
|||||||
lora = data.get('lora', {})
|
lora = data.get('lora', {})
|
||||||
if lora.get('lora_triggers'):
|
if lora.get('lora_triggers'):
|
||||||
parts.append(lora['lora_triggers'])
|
parts.append(lora['lora_triggers'])
|
||||||
parts.extend(data.get('tags', []))
|
|
||||||
for key in _BODY_GROUP_KEYS:
|
for key in _BODY_GROUP_KEYS:
|
||||||
val = data.get('action', {}).get(key)
|
val = data.get('action', {}).get(key)
|
||||||
if val:
|
if val:
|
||||||
@@ -299,7 +294,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
|
|||||||
lora = data.get('lora', {})
|
lora = data.get('lora', {})
|
||||||
if lora.get('lora_triggers'):
|
if lora.get('lora_triggers'):
|
||||||
parts.append(lora['lora_triggers'])
|
parts.append(lora['lora_triggers'])
|
||||||
parts.extend(data.get('tags', []))
|
|
||||||
|
|
||||||
for scene in scenes:
|
for scene in scenes:
|
||||||
data = scene.data
|
data = scene.data
|
||||||
@@ -311,7 +305,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
|
|||||||
lora = data.get('lora', {})
|
lora = data.get('lora', {})
|
||||||
if lora.get('lora_triggers'):
|
if lora.get('lora_triggers'):
|
||||||
parts.append(lora['lora_triggers'])
|
parts.append(lora['lora_triggers'])
|
||||||
parts.extend(data.get('tags', []))
|
|
||||||
|
|
||||||
for style in styles:
|
for style in styles:
|
||||||
data = style.data
|
data = style.data
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ from models import (
|
|||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_nsfw_from_tags(entity, data):
|
||||||
|
"""Sync is_nsfw from data['tags']['nsfw'] if tags is a dict. Never touches is_favourite."""
|
||||||
|
tags = data.get('tags')
|
||||||
|
if isinstance(tags, dict):
|
||||||
|
entity.is_nsfw = bool(tags.get('nsfw', False))
|
||||||
|
|
||||||
|
|
||||||
def sync_characters():
|
def sync_characters():
|
||||||
if not os.path.exists(current_app.config['CHARACTERS_DIR']):
|
if not os.path.exists(current_app.config['CHARACTERS_DIR']):
|
||||||
return
|
return
|
||||||
@@ -44,6 +51,7 @@ def sync_characters():
|
|||||||
character.name = name
|
character.name = name
|
||||||
character.slug = slug
|
character.slug = slug
|
||||||
character.filename = filename
|
character.filename = filename
|
||||||
|
_sync_nsfw_from_tags(character, data)
|
||||||
|
|
||||||
# Check if cover image still exists
|
# Check if cover image still exists
|
||||||
if character.image_path:
|
if character.image_path:
|
||||||
@@ -62,6 +70,7 @@ def sync_characters():
|
|||||||
name=name,
|
name=name,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
|
_sync_nsfw_from_tags(new_char, data)
|
||||||
db.session.add(new_char)
|
db.session.add(new_char)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing {filename}: {e}")
|
print(f"Error importing {filename}: {e}")
|
||||||
@@ -102,6 +111,7 @@ def sync_outfits():
|
|||||||
outfit.name = name
|
outfit.name = name
|
||||||
outfit.slug = slug
|
outfit.slug = slug
|
||||||
outfit.filename = filename
|
outfit.filename = filename
|
||||||
|
_sync_nsfw_from_tags(outfit, data)
|
||||||
|
|
||||||
# Check if cover image still exists
|
# Check if cover image still exists
|
||||||
if outfit.image_path:
|
if outfit.image_path:
|
||||||
@@ -120,6 +130,7 @@ def sync_outfits():
|
|||||||
name=name,
|
name=name,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
|
_sync_nsfw_from_tags(new_outfit, data)
|
||||||
db.session.add(new_outfit)
|
db.session.add(new_outfit)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing outfit {filename}: {e}")
|
print(f"Error importing outfit {filename}: {e}")
|
||||||
@@ -243,6 +254,7 @@ def sync_looks():
|
|||||||
look.slug = slug
|
look.slug = slug
|
||||||
look.filename = filename
|
look.filename = filename
|
||||||
look.character_id = character_id
|
look.character_id = character_id
|
||||||
|
_sync_nsfw_from_tags(look, data)
|
||||||
|
|
||||||
if look.image_path:
|
if look.image_path:
|
||||||
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], look.image_path)
|
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], look.image_path)
|
||||||
@@ -259,6 +271,7 @@ def sync_looks():
|
|||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
|
_sync_nsfw_from_tags(new_look, data)
|
||||||
db.session.add(new_look)
|
db.session.add(new_look)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing look {filename}: {e}")
|
print(f"Error importing look {filename}: {e}")
|
||||||
@@ -418,6 +431,7 @@ def sync_actions():
|
|||||||
action.name = name
|
action.name = name
|
||||||
action.slug = slug
|
action.slug = slug
|
||||||
action.filename = filename
|
action.filename = filename
|
||||||
|
_sync_nsfw_from_tags(action, data)
|
||||||
|
|
||||||
# Check if cover image still exists
|
# Check if cover image still exists
|
||||||
if action.image_path:
|
if action.image_path:
|
||||||
@@ -435,6 +449,7 @@ def sync_actions():
|
|||||||
name=name,
|
name=name,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
|
_sync_nsfw_from_tags(new_action, data)
|
||||||
db.session.add(new_action)
|
db.session.add(new_action)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing action {filename}: {e}")
|
print(f"Error importing action {filename}: {e}")
|
||||||
@@ -475,6 +490,7 @@ def sync_styles():
|
|||||||
style.name = name
|
style.name = name
|
||||||
style.slug = slug
|
style.slug = slug
|
||||||
style.filename = filename
|
style.filename = filename
|
||||||
|
_sync_nsfw_from_tags(style, data)
|
||||||
|
|
||||||
# Check if cover image still exists
|
# Check if cover image still exists
|
||||||
if style.image_path:
|
if style.image_path:
|
||||||
@@ -492,6 +508,7 @@ def sync_styles():
|
|||||||
name=name,
|
name=name,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
|
_sync_nsfw_from_tags(new_style, data)
|
||||||
db.session.add(new_style)
|
db.session.add(new_style)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing style {filename}: {e}")
|
print(f"Error importing style {filename}: {e}")
|
||||||
@@ -532,6 +549,7 @@ def sync_detailers():
|
|||||||
detailer.name = name
|
detailer.name = name
|
||||||
detailer.slug = slug
|
detailer.slug = slug
|
||||||
detailer.filename = filename
|
detailer.filename = filename
|
||||||
|
_sync_nsfw_from_tags(detailer, data)
|
||||||
|
|
||||||
# Check if cover image still exists
|
# Check if cover image still exists
|
||||||
if detailer.image_path:
|
if detailer.image_path:
|
||||||
@@ -549,6 +567,7 @@ def sync_detailers():
|
|||||||
name=name,
|
name=name,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
|
_sync_nsfw_from_tags(new_detailer, data)
|
||||||
db.session.add(new_detailer)
|
db.session.add(new_detailer)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing detailer {filename}: {e}")
|
print(f"Error importing detailer {filename}: {e}")
|
||||||
@@ -589,6 +608,7 @@ def sync_scenes():
|
|||||||
scene.name = name
|
scene.name = name
|
||||||
scene.slug = slug
|
scene.slug = slug
|
||||||
scene.filename = filename
|
scene.filename = filename
|
||||||
|
_sync_nsfw_from_tags(scene, data)
|
||||||
|
|
||||||
# Check if cover image still exists
|
# Check if cover image still exists
|
||||||
if scene.image_path:
|
if scene.image_path:
|
||||||
@@ -606,6 +626,7 @@ def sync_scenes():
|
|||||||
name=name,
|
name=name,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
|
_sync_nsfw_from_tags(new_scene, data)
|
||||||
db.session.add(new_scene)
|
db.session.add(new_scene)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing scene {filename}: {e}")
|
print(f"Error importing scene {filename}: {e}")
|
||||||
@@ -679,19 +700,22 @@ def sync_checkpoints():
|
|||||||
ckpt.slug = slug
|
ckpt.slug = slug
|
||||||
ckpt.checkpoint_path = checkpoint_path
|
ckpt.checkpoint_path = checkpoint_path
|
||||||
ckpt.data = data
|
ckpt.data = data
|
||||||
|
_sync_nsfw_from_tags(ckpt, data)
|
||||||
flag_modified(ckpt, "data")
|
flag_modified(ckpt, "data")
|
||||||
if ckpt.image_path:
|
if ckpt.image_path:
|
||||||
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], ckpt.image_path)
|
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], ckpt.image_path)
|
||||||
if not os.path.exists(full_img_path):
|
if not os.path.exists(full_img_path):
|
||||||
ckpt.image_path = None
|
ckpt.image_path = None
|
||||||
else:
|
else:
|
||||||
db.session.add(Checkpoint(
|
new_ckpt = Checkpoint(
|
||||||
checkpoint_id=checkpoint_id,
|
checkpoint_id=checkpoint_id,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
name=display_name,
|
name=display_name,
|
||||||
checkpoint_path=checkpoint_path,
|
checkpoint_path=checkpoint_path,
|
||||||
data=data,
|
data=data,
|
||||||
))
|
)
|
||||||
|
_sync_nsfw_from_tags(new_ckpt, data)
|
||||||
|
db.session.add(new_ckpt)
|
||||||
|
|
||||||
all_ckpts = Checkpoint.query.all()
|
all_ckpts = Checkpoint.query.all()
|
||||||
for ckpt in all_ckpts:
|
for ckpt in all_ckpts:
|
||||||
|
|||||||
@@ -10,23 +10,23 @@
|
|||||||
<form action="{{ url_for('create_action') }}" method="post">
|
<form action="{{ url_for('create_action') }}" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Action Name</label>
|
<label for="name" class="form-label">Action Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Belly Dancing" required>
|
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Belly Dancing" value="{{ form_data.get('name', '') }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
||||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. belly_dancing">
|
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. belly_dancing" value="{{ form_data.get('filename', '') }}">
|
||||||
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 form-check form-switch">
|
<div class="mb-3 form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
|
||||||
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3" id="prompt-group">
|
<div class="mb-3" id="prompt-group">
|
||||||
<label for="prompt" class="form-label">Description / Concept</label>
|
<label for="prompt" class="form-label">Description / Concept</label>
|
||||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the action, pose, or movement. The AI will generate the full action profile based on this."></textarea>
|
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the action, pose, or movement. The AI will generate the full action profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
|
||||||
<div class="form-text">Required when AI generation is enabled.</div>
|
<div class="form-text">Required when AI generation is enabled.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -111,33 +111,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set tags = action.data.tags if action.data.tags is mapping else {} %}
|
||||||
|
{% if tags %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||||
<span>Tags</span>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
|
||||||
{% if preferences is not none %}
|
|
||||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
|
||||||
{% elif action.default_fields is not none %}
|
|
||||||
{% if 'special::tags' in action.default_fields %}checked{% endif %}
|
|
||||||
{% endif %}>
|
|
||||||
<label class="form-check-label text-white small {% if action.default_fields is not none and 'special::tags' in action.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% for tag in action.data.tags %}
|
{% if tags.participants %}<span class="badge bg-info">{{ tags.participants }}</span>{% endif %}
|
||||||
<span class="badge bg-secondary">{{ tag }}</span>
|
{% if action.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||||
{% else %}
|
{% if action.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||||
<span class="text-muted">No tags</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-0">{{ action.name }}</h1>
|
<h1 class="mb-0">
|
||||||
|
{{ action.name }}
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/action/{{ action.slug }}/favourite" title="Toggle favourite">
|
||||||
|
<span style="font-size:1.2rem;">{% if action.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
{% if action.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h1>
|
||||||
<a href="{{ url_for('edit_action', slug=action.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
<a href="{{ url_for('edit_action', slug=action.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
||||||
<form action="{{ url_for('clone_action', slug=action.slug) }}" method="post" style="display: inline;">
|
<form action="{{ url_for('clone_action', slug=action.slug) }}" method="post" style="display: inline;">
|
||||||
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Action</button>
|
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Action</button>
|
||||||
@@ -145,6 +141,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('actions', '{{ action.slug }}')">Regenerate Tags</button>
|
||||||
<a href="{{ url_for('transfer_resource', category='actions', slug=action.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
<a href="{{ url_for('transfer_resource', category='actions', slug=action.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||||
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,6 +296,16 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favourite toggle
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
const form = document.getElementById('generate-form');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
|||||||
@@ -22,9 +22,24 @@
|
|||||||
<label for="action_id" class="form-label">Action ID</label>
|
<label for="action_id" class="form-label">Action ID</label>
|
||||||
<input type="text" class="form-control" id="action_id" name="action_id" value="{{ action.action_id }}">
|
<input type="text" class="form-control" id="action_id" name="action_id" value="{{ action.action_id }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
{% set tags = action.data.tags if action.data.tags is mapping else {} %}
|
||||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
<div class="row">
|
||||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ action.data.tags | join(', ') }}">
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="tag_participants" class="form-label">Participants</label>
|
||||||
|
<input type="text" class="form-control" id="tag_participants" name="tag_participants" value="{{ tags.participants or '' }}" placeholder="e.g. solo, 1girl 1boy, 2girls">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if action.is_nsfw %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="suppress_wardrobe" name="suppress_wardrobe" {% if action.data.get('suppress_wardrobe') %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="suppress_wardrobe">Suppress Wardrobe</label>
|
||||||
|
<div class="form-text">When enabled, no clothing/wardrobe prompts are injected during generation.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
{{ library_toolbar(
|
||||||
<h2>Action Library</h2>
|
title="Action",
|
||||||
<div class="d-flex gap-1 align-items-center">
|
category="actions",
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_url=url_for('create_action'),
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_label="Action",
|
||||||
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents">
|
has_batch_gen=true,
|
||||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new action entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_regen_all=true,
|
||||||
</form>
|
has_lora_create=true,
|
||||||
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents">
|
bulk_create_url=url_for('bulk_create_actions_from_loras'),
|
||||||
<input type="hidden" name="overwrite" value="true">
|
has_tags=true,
|
||||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all action metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL action LoRAs, consuming significant API credits and overwriting ALL existing action metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
regen_tags_category="actions",
|
||||||
</form>
|
rescan_url=url_for('rescan_actions'),
|
||||||
<a href="{{ url_for('create_action') }}" class="btn btn-sm btn-success">Create New Action</a>
|
get_missing_url="/get_missing_actions",
|
||||||
<form action="{{ url_for('rescan_actions') }}" method="post" class="d-contents">
|
clear_covers_url="/clear_all_action_covers",
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan action files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
generate_url_pattern="/action/{slug}/generate"
|
||||||
</form>
|
) }}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||||
{% for action in actions %}
|
{% for action in actions %}
|
||||||
@@ -40,7 +52,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-center">{{ action.name }}</h5>
|
<h5 class="card-title text-center">{% if action.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ action.name }}{% if action.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||||
<p class="card-text small text-center text-muted">
|
<p class="card-text small text-center text-muted">
|
||||||
{% set ns = namespace(parts=[]) %}
|
{% set ns = namespace(parts=[]) %}
|
||||||
{% if action.data.action is mapping %}
|
{% if action.data.action is mapping %}
|
||||||
@@ -80,111 +92,11 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Handle highlight parameter
|
|
||||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||||
if (highlightSlug) {
|
if (highlightSlug) {
|
||||||
const card = document.getElementById(`card-${highlightSlug}`);
|
const card = document.getElementById(`card-${highlightSlug}`);
|
||||||
if (card) {
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const batchBtn = document.getElementById('batch-generate-btn');
|
|
||||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
|
||||||
const itemNameText = document.getElementById('current-item-name');
|
|
||||||
const stepProgressText = document.getElementById('current-step-progress');
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
|
||||||
else nodeStatus.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBatch() {
|
|
||||||
const response = await fetch('/get_missing_actions');
|
|
||||||
const data = await response.json();
|
|
||||||
const missing = data.missing;
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
alert("No actions missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = true;
|
|
||||||
regenAllBtn.disabled = true;
|
|
||||||
|
|
||||||
// Phase 1: Queue all jobs upfront
|
|
||||||
|
|
||||||
const jobs = [];
|
|
||||||
for (const item of missing) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
const genResp = await fetch(`/action/${item.slug}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const genData = await genResp.json();
|
|
||||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to queue ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Poll all concurrently
|
|
||||||
let currentItem = '';
|
|
||||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
|
||||||
currentItem = item.name;
|
|
||||||
itemNameText.textContent = `Processing: ${currentItem}`;
|
|
||||||
try {
|
|
||||||
const jobResult = await waitForJob(jobId);
|
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
|
||||||
const img = document.getElementById(`img-${item.slug}`);
|
|
||||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
|
||||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
|
||||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed for ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
batchBtn.disabled = false;
|
|
||||||
regenAllBtn.disabled = false;
|
|
||||||
alert(`Batch generation complete! ${jobs.length} action images processed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.addEventListener('click', async () => {
|
|
||||||
const response = await fetch('/get_missing_actions');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.missing.length === 0) {
|
|
||||||
alert("No actions missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Generate cover images for ${data.missing.length} actions?`)) return;
|
|
||||||
runBatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
regenAllBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm("This will unassign ALL current action cover images and generate new ones. Proceed?")) return;
|
|
||||||
|
|
||||||
const clearResp = await fetch('/clear_all_action_covers', { method: 'POST' });
|
|
||||||
if (clearResp.ok) {
|
|
||||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
|
||||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
|
||||||
runBatch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -106,7 +106,12 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-0">{{ ckpt.name }}</h1>
|
<h1 class="mb-0">
|
||||||
|
{{ ckpt.name }}
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/checkpoint/{{ ckpt.slug }}/favourite" title="Toggle favourite">
|
||||||
|
<span style="font-size:1.2rem;">{% if ckpt.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||||
@@ -232,6 +237,16 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favourite toggle
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
const form = document.getElementById('generate-form');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||||
<h2>Checkpoint Library</h2>
|
{{ library_toolbar(
|
||||||
<div class="d-flex gap-1 align-items-center">
|
title="Checkpoint",
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
category="checkpoints",
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_url=none,
|
||||||
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents">
|
has_batch_gen=true,
|
||||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new checkpoint entries from all checkpoint files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_regen_all=true,
|
||||||
</form>
|
has_lora_create=true,
|
||||||
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents">
|
bulk_create_url=url_for('bulk_create_checkpoints'),
|
||||||
<input type="hidden" name="overwrite" value="true">
|
has_tags=false,
|
||||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all checkpoint metadata (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL checkpoints, consuming API credits and overwriting ALL existing metadata. Are you sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
rescan_url=url_for('rescan_checkpoints'),
|
||||||
</form>
|
get_missing_url="/get_missing_checkpoints",
|
||||||
<form action="{{ url_for('rescan_checkpoints') }}" method="post" class="d-contents">
|
clear_covers_url="/clear_all_checkpoint_covers",
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan checkpoint files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
generate_url_pattern="/checkpoint/{slug}/generate"
|
||||||
</form>
|
) }}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||||
{% for ckpt in checkpoints %}
|
{% for ckpt in checkpoints %}
|
||||||
@@ -39,7 +50,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-center">{{ ckpt.name }}</h5>
|
<h5 class="card-title text-center">
|
||||||
|
{% if ckpt.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ ckpt.name }}
|
||||||
|
{% if ckpt.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center p-1">
|
<div class="card-footer d-flex justify-content-between align-items-center p-1">
|
||||||
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
|
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
|
||||||
@@ -53,101 +67,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const batchBtn = document.getElementById('batch-generate-btn');
|
|
||||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
|
||||||
const ckptNameText = document.getElementById('current-ckpt-name');
|
|
||||||
const stepProgressText = document.getElementById('current-step-progress');
|
|
||||||
|
|
||||||
let currentJobId = null;
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
|
||||||
else nodeStatus.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBatch() {
|
|
||||||
const response = await fetch('/get_missing_checkpoints');
|
|
||||||
const data = await response.json();
|
|
||||||
const missing = data.missing;
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
alert('No checkpoints missing cover images.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = true;
|
|
||||||
regenAllBtn.disabled = true;
|
|
||||||
|
|
||||||
// Phase 1: Queue all jobs upfront
|
|
||||||
|
|
||||||
const jobs = [];
|
|
||||||
for (const ckpt of missing) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({ character_slug: '__random__' }),
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const genData = await genResp.json();
|
|
||||||
if (genData.job_id) jobs.push({ item: ckpt, jobId: genData.job_id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to queue ${ckpt.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Poll all concurrently
|
|
||||||
let currentItem = '';
|
|
||||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
|
||||||
currentItem = item.name;
|
|
||||||
ckptNameText.textContent = `Processing: ${currentItem}`;
|
|
||||||
try {
|
|
||||||
const jobResult = await waitForJob(jobId);
|
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
|
||||||
const img = document.getElementById(`img-${item.slug}`);
|
|
||||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
|
||||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
|
||||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed for ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
batchBtn.disabled = false;
|
|
||||||
regenAllBtn.disabled = false;
|
|
||||||
alert(`Batch generation complete! ${jobs.length} checkpoint images processed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.addEventListener('click', async () => {
|
|
||||||
const response = await fetch('/get_missing_checkpoints');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.missing.length === 0) { alert('No checkpoints missing cover images.'); return; }
|
|
||||||
if (!confirm(`Generate cover images for ${data.missing.length} checkpoints?`)) return;
|
|
||||||
runBatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
regenAllBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm('This will unassign ALL current checkpoint cover images and generate new ones. Proceed?')) return;
|
|
||||||
const clearResp = await fetch('/clear_all_checkpoint_covers', { method: 'POST' });
|
|
||||||
if (clearResp.ok) {
|
|
||||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
|
||||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
|
||||||
runBatch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,26 +10,32 @@
|
|||||||
<form action="{{ url_for('create_character') }}" method="post">
|
<form action="{{ url_for('create_character') }}" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Character Name</label>
|
<label for="name" class="form-label">Character Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Cyberpunk Ninja" required>
|
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Cyberpunk Ninja" value="{{ form_data.get('name', '') }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
||||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. cyberpunk_ninja">
|
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. cyberpunk_ninja" value="{{ form_data.get('filename', '') }}">
|
||||||
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 form-check form-switch">
|
<div class="mb-3 form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
|
||||||
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3" id="prompt-group">
|
<div class="mb-3" id="prompt-group">
|
||||||
<label for="prompt" class="form-label">Description / Concept</label>
|
<label for="prompt" class="form-label">Description / Concept</label>
|
||||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the character's appearance, clothing, style, and personality. The AI will generate the full profile based on this."></textarea>
|
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the character's appearance, clothing, style, and personality. The AI will generate the full profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
|
||||||
<div class="form-text">Required when AI generation is enabled.</div>
|
<div class="form-text">Required when AI generation is enabled.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="wiki-url-group">
|
||||||
|
<label for="wiki_url" class="form-label">Wiki / Reference URL <small class="text-muted">- optional</small></label>
|
||||||
|
<input type="url" class="form-control" id="wiki_url" name="wiki_url" placeholder="e.g. https://finalfantasy.fandom.com/wiki/Tifa_Lockhart" value="{{ form_data.get('wiki_url', '') }}">
|
||||||
|
<div class="form-text">Fandom wiki URL or other character page. The AI will use this as reference for accurate appearance details.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-info" id="ai-info">
|
<div class="alert alert-info" id="ai-info">
|
||||||
<i class="bi bi-info-circle"></i> The AI will generate a complete character profile based on your description.
|
<i class="bi bi-info-circle"></i> The AI will generate a complete character profile based on your description.
|
||||||
</div>
|
</div>
|
||||||
@@ -51,6 +57,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.getElementById('use_llm').addEventListener('change', function() {
|
document.getElementById('use_llm').addEventListener('change', function() {
|
||||||
const promptGroup = document.getElementById('prompt-group');
|
const promptGroup = document.getElementById('prompt-group');
|
||||||
|
const wikiUrlGroup = document.getElementById('wiki-url-group');
|
||||||
const aiInfo = document.getElementById('ai-info');
|
const aiInfo = document.getElementById('ai-info');
|
||||||
const manualInfo = document.getElementById('manual-info');
|
const manualInfo = document.getElementById('manual-info');
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
@@ -58,12 +65,14 @@ document.getElementById('use_llm').addEventListener('change', function() {
|
|||||||
|
|
||||||
if (this.checked) {
|
if (this.checked) {
|
||||||
promptGroup.classList.remove('d-none');
|
promptGroup.classList.remove('d-none');
|
||||||
|
wikiUrlGroup.classList.remove('d-none');
|
||||||
aiInfo.classList.remove('d-none');
|
aiInfo.classList.remove('d-none');
|
||||||
manualInfo.classList.add('d-none');
|
manualInfo.classList.add('d-none');
|
||||||
submitBtn.textContent = 'Create & Generate';
|
submitBtn.textContent = 'Create & Generate';
|
||||||
promptInput.required = true;
|
promptInput.required = true;
|
||||||
} else {
|
} else {
|
||||||
promptGroup.classList.add('d-none');
|
promptGroup.classList.add('d-none');
|
||||||
|
wikiUrlGroup.classList.add('d-none');
|
||||||
aiInfo.classList.add('d-none');
|
aiInfo.classList.add('d-none');
|
||||||
manualInfo.classList.remove('d-none');
|
manualInfo.classList.remove('d-none');
|
||||||
submitBtn.textContent = 'Create Character';
|
submitBtn.textContent = 'Create Character';
|
||||||
|
|||||||
@@ -59,31 +59,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set tags = character.data.tags if character.data.tags is mapping else {} %}
|
||||||
|
{% if tags %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||||
<span>Tags</span>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
|
||||||
{% if preferences is not none %}
|
|
||||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
|
||||||
{% elif character.default_fields is not none %}
|
|
||||||
{% if 'special::tags' in character.default_fields %}checked{% endif %}
|
|
||||||
{% endif %}>
|
|
||||||
<label class="form-check-label text-white small {% if character.default_fields is not none and 'special::tags' in character.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% for tag in character.data.tags %}
|
{% if tags.origin_series %}<span class="badge bg-info">{{ tags.origin_series }}</span>{% endif %}
|
||||||
<span class="badge bg-secondary">{{ tag }}</span>
|
{% if tags.origin_type %}<span class="badge bg-primary">{{ tags.origin_type }}</span>{% endif %}
|
||||||
{% endfor %}
|
{% if character.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||||
|
{% if character.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-0">{{ character.name }}</h1>
|
<h1 class="mb-0">
|
||||||
|
{{ character.name }}
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/character/{{ character.slug }}/favourite" title="Toggle favourite">
|
||||||
|
<span style="font-size:1.2rem;">{% if character.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
{% if character.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h1>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
||||||
<a href="{{ url_for('transfer_character', slug=character.slug) }}" class="btn btn-sm btn-warning text-decoration-none">
|
<a href="{{ url_for('transfer_character', slug=character.slug) }}" class="btn btn-sm btn-warning text-decoration-none">
|
||||||
@@ -91,8 +90,11 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('characters', '{{ character.slug }}')">Regenerate Tags</button>
|
||||||
<a href="/" class="btn btn-outline-secondary">Back to Library</a>
|
<a href="/" class="btn btn-outline-secondary">Back to Library</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist">
|
<ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
@@ -271,6 +273,16 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favourite toggle
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
const form = document.getElementById('generate-form');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<form action="{{ url_for('create_detailer') }}" method="post">
|
<form action="{{ url_for('create_detailer') }}" method="post">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="name" class="form-label fw-bold">Detailer Name</label>
|
<label for="name" class="form-label fw-bold">Detailer Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Eye Detail Glossy" required>
|
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Eye Detail Glossy" value="{{ form_data.get('name', '') }}" required>
|
||||||
<div class="form-text">The display name for the detailer gallery.</div>
|
<div class="form-text">The display name for the detailer gallery.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="filename" class="form-label fw-bold">Detailer ID / Filename <small class="text-muted">(Optional)</small></label>
|
<label for="filename" class="form-label fw-bold">Detailer ID / Filename <small class="text-muted">(Optional)</small></label>
|
||||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. eye_detail_glossy">
|
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. eye_detail_glossy" value="{{ form_data.get('filename', '') }}">
|
||||||
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -127,13 +127,20 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-0">{{ detailer.name }}</h1>
|
<h1 class="mb-0">
|
||||||
|
{{ detailer.name }}
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/detailer/{{ detailer.slug }}/favourite" title="Toggle favourite">
|
||||||
|
<span style="font-size:1.2rem;">{% if detailer.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
{% if detailer.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h1>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<a href="{{ url_for('edit_detailer', slug=detailer.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Detailer</a>
|
<a href="{{ url_for('edit_detailer', slug=detailer.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Detailer</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('detailers', '{{ detailer.slug }}')">Regenerate Tags</button>
|
||||||
<a href="{{ url_for('transfer_resource', category='detailers', slug=detailer.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
<a href="{{ url_for('transfer_resource', category='detailers', slug=detailer.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||||
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,6 +267,16 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favourite toggle
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
const form = document.getElementById('generate-form');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
|||||||
@@ -74,11 +74,34 @@
|
|||||||
value="{{ detailer.data.prompt or '' }}">
|
value="{{ detailer.data.prompt or '' }}">
|
||||||
<div class="form-text">Comma-separated tags, e.g. "glossy eyes, detailed irises"</div>
|
<div class="form-text">Comma-separated tags, e.g. "glossy eyes, detailed irises"</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
{% set tags = detailer.data.tags if detailer.data.tags is mapping else {} %}
|
||||||
<label for="tags" class="form-label">Extra Tags</label>
|
<div class="row">
|
||||||
<input type="text" class="form-control" id="tags" name="tags"
|
<div class="col-md-4 mb-3">
|
||||||
value="{{ detailer.data.tags | join(', ') if detailer.data.tags else '' }}">
|
<label for="tag_associated_resource" class="form-label">Associated Resource</label>
|
||||||
<div class="form-text">Comma-separated extra tags appended to every generation.</div>
|
<select class="form-select" id="tag_associated_resource" name="tag_associated_resource">
|
||||||
|
{% for opt in ['', 'General', 'Looks', 'Styles', 'Faces', 'NSFW'] %}
|
||||||
|
<option value="{{ opt }}" {% if tags.associated_resource == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 mb-3">
|
||||||
|
<label class="form-label">ADetailer Targets</label>
|
||||||
|
<div>
|
||||||
|
{% for target in ['face', 'hands', 'body', 'nsfw'] %}
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_target_{{ target }}" name="tag_adetailer_targets" value="{{ target }}" {% if tags.adetailer_targets is defined and target in tags.adetailer_targets %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="tag_target_{{ target }}">{{ target|capitalize }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if detailer.is_nsfw %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
{{ library_toolbar(
|
||||||
<h2>Detailer Library</h2>
|
title="Detailer",
|
||||||
<div class="d-flex gap-1 align-items-center">
|
category="detailers",
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_url=url_for('create_detailer'),
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_label="Detailer",
|
||||||
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents">
|
has_batch_gen=true,
|
||||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new detailer entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_regen_all=true,
|
||||||
</form>
|
has_lora_create=true,
|
||||||
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents">
|
bulk_create_url=url_for('bulk_create_detailers_from_loras'),
|
||||||
<input type="hidden" name="overwrite" value="true">
|
has_tags=true,
|
||||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all detailer metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL detailer LoRAs, consuming significant API credits and overwriting ALL existing detailer metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
regen_tags_category="detailers",
|
||||||
</form>
|
rescan_url=url_for('rescan_detailers'),
|
||||||
<a href="{{ url_for('create_detailer') }}" class="btn btn-sm btn-success">Create New Detailer</a>
|
get_missing_url="/get_missing_detailers",
|
||||||
<form action="{{ url_for('rescan_detailers') }}" method="post" class="d-contents">
|
clear_covers_url="/clear_all_detailer_covers",
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan detailer files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
generate_url_pattern="/detailer/{slug}/generate"
|
||||||
</form>
|
) }}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||||
{% for detailer in detailers %}
|
{% for detailer in detailers %}
|
||||||
@@ -40,7 +52,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-center">{{ detailer.name }}</h5>
|
<h5 class="card-title text-center">{% if detailer.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ detailer.name }}{% if detailer.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||||
<p class="card-text small text-center text-muted">
|
<p class="card-text small text-center text-muted">
|
||||||
{% set ns = namespace(parts=[]) %}
|
{% set ns = namespace(parts=[]) %}
|
||||||
{% if detailer.data.prompt is string %}
|
{% if detailer.data.prompt is string %}
|
||||||
@@ -82,111 +94,11 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Handle highlight parameter
|
|
||||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||||
if (highlightSlug) {
|
if (highlightSlug) {
|
||||||
const card = document.getElementById(`card-${highlightSlug}`);
|
const card = document.getElementById(`card-${highlightSlug}`);
|
||||||
if (card) {
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const batchBtn = document.getElementById('batch-generate-btn');
|
|
||||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
|
||||||
const detailerNameText = document.getElementById('current-detailer-name');
|
|
||||||
const stepProgressText = document.getElementById('current-step-progress');
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
|
||||||
else nodeStatus.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBatch() {
|
|
||||||
const response = await fetch('/get_missing_detailers');
|
|
||||||
const data = await response.json();
|
|
||||||
const missing = data.missing;
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
alert("No detailers missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = true;
|
|
||||||
regenAllBtn.disabled = true;
|
|
||||||
|
|
||||||
// Phase 1: Queue all jobs upfront
|
|
||||||
|
|
||||||
const jobs = [];
|
|
||||||
for (const item of missing) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
const genResp = await fetch(`/detailer/${item.slug}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const genData = await genResp.json();
|
|
||||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to queue ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Poll all concurrently
|
|
||||||
let currentItem = '';
|
|
||||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
|
||||||
currentItem = item.name;
|
|
||||||
detailerNameText.textContent = `Processing: ${currentItem}`;
|
|
||||||
try {
|
|
||||||
const jobResult = await waitForJob(jobId);
|
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
|
||||||
const img = document.getElementById(`img-${item.slug}`);
|
|
||||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
|
||||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
|
||||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed for ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
batchBtn.disabled = false;
|
|
||||||
regenAllBtn.disabled = false;
|
|
||||||
alert(`Batch generation complete! ${jobs.length} detailer images processed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.addEventListener('click', async () => {
|
|
||||||
const response = await fetch('/get_missing_detailers');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.missing.length === 0) {
|
|
||||||
alert("No detailers missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Generate cover images for ${data.missing.length} detailers?`)) return;
|
|
||||||
runBatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
regenAllBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm("This will unassign ALL current detailer cover images and generate new ones. Proceed?")) return;
|
|
||||||
|
|
||||||
const clearResp = await fetch('/clear_all_detailer_covers', { method: 'POST' });
|
|
||||||
if (clearResp.ok) {
|
|
||||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
|
||||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
|
||||||
runBatch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -18,9 +18,27 @@
|
|||||||
<label for="character_name" class="form-label">Display Name</label>
|
<label for="character_name" class="form-label">Display Name</label>
|
||||||
<input type="text" class="form-control" id="character_name" name="character_name" value="{{ character.name }}" required>
|
<input type="text" class="form-control" id="character_name" name="character_name" value="{{ character.name }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
{% set tags = character.data.tags if character.data.tags is mapping else {} %}
|
||||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
<div class="row">
|
||||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ character.data.tags | join(', ') }}">
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="tag_origin_series" class="form-label">Origin Series</label>
|
||||||
|
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="tag_origin_type" class="form-label">Origin Type</label>
|
||||||
|
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
|
||||||
|
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
|
||||||
|
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if character.is_nsfw %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +139,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% set wardrobe_data = character.data.wardrobe %}
|
{% set wardrobe_data = character.data.get('wardrobe', {}) %}
|
||||||
{% set outfits = character.get_available_outfits() %}
|
{% set outfits = character.get_available_outfits() %}
|
||||||
{% if wardrobe_data.default is defined and wardrobe_data.default is mapping %}
|
{% if wardrobe_data.default is defined and wardrobe_data.default is mapping %}
|
||||||
{# New nested format - show tabs for each outfit #}
|
{# New nested format - show tabs for each outfit #}
|
||||||
|
|||||||
@@ -57,23 +57,50 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Favourite filter -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label form-label-sm mb-1"> </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">★ Favourites</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NSFW filter -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label form-label-sm mb-1">Rating</label>
|
||||||
|
<select name="nsfw" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Active filter chips -->
|
<!-- Active filter chips -->
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% if category != 'all' %}
|
{% if category != 'all' %}
|
||||||
<span class="badge bg-primary me-1">
|
<span class="badge bg-primary me-1">
|
||||||
{{ category | capitalize }}
|
{{ category | capitalize }}
|
||||||
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
|
<a href="{{ url_for('gallery', sort=sort, per_page=per_page, xref_category=xref_category, xref_slug=xref_slug) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if slug %}
|
{% if slug %}
|
||||||
<span class="badge bg-secondary me-1">
|
<span class="badge bg-secondary me-1">
|
||||||
{{ slug }}
|
{{ slug }}
|
||||||
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
|
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page, xref_category=xref_category, xref_slug=xref_slug) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if xref_category and xref_slug %}
|
||||||
|
<span class="badge bg-info me-1">
|
||||||
|
Cross-ref: {{ xref_category | capitalize }} = {{ xref_slug }}
|
||||||
|
<a href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="page" value="1">
|
<input type="hidden" name="page" value="1">
|
||||||
|
{% if xref_category %}<input type="hidden" name="xref_category" value="{{ xref_category }}">{% endif %}
|
||||||
|
{% if xref_slug %}<input type="hidden" name="xref_slug" value="{{ xref_slug }}">{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -117,6 +144,17 @@
|
|||||||
class="badge {% if category == 'checkpoints' %}bg-dark{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
|
class="badge {% if category == 'checkpoints' %}bg-dark{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
|
||||||
Checkpoints
|
Checkpoints
|
||||||
</a>
|
</a>
|
||||||
|
<span class="text-muted small mx-1">|</span>
|
||||||
|
<a href="{{ url_for('gallery', category='presets', sort=sort, per_page=per_page) }}"
|
||||||
|
class="badge {% if category == 'presets' %}text-white{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"
|
||||||
|
{% if category == 'presets' %}style="background-color: #6f42c1"{% endif %}>
|
||||||
|
Presets
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('gallery', category='generator', sort=sort, per_page=per_page) }}"
|
||||||
|
class="badge {% if category == 'generator' %}text-white{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"
|
||||||
|
{% if category == 'generator' %}style="background-color: #20c997"{% endif %}>
|
||||||
|
Generator
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,6 +260,9 @@
|
|||||||
'styles': 'warning',
|
'styles': 'warning',
|
||||||
'detailers': 'secondary',
|
'detailers': 'secondary',
|
||||||
'checkpoints': 'dark',
|
'checkpoints': 'dark',
|
||||||
|
'looks': 'primary',
|
||||||
|
'presets': 'purple',
|
||||||
|
'generator': 'teal',
|
||||||
} %}
|
} %}
|
||||||
{% for img in images %}
|
{% for img in images %}
|
||||||
<div class="gallery-card"
|
<div class="gallery-card"
|
||||||
@@ -237,6 +278,8 @@
|
|||||||
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
|
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
|
||||||
{{ img.category[:-1] if img.category.endswith('s') else img.category }}
|
{{ img.category[:-1] if img.category.endswith('s') else img.category }}
|
||||||
</span>
|
</span>
|
||||||
|
{% if img._sidecar.get('is_favourite') %}<span class="fav-badge" title="Favourite" onclick="event.stopPropagation(); toggleImageFavourite('{{ img.path }}', this)">★</span>{% else %}<span class="fav-badge fav-off" title="Mark as favourite" onclick="event.stopPropagation(); toggleImageFavourite('{{ img.path }}', this)">☆</span>{% endif %}
|
||||||
|
{% if img._sidecar.get('is_nsfw') %}<span class="nsfw-badge badge bg-danger" style="position:absolute;top:4px;right:4px;font-size:0.6rem;">NSFW</span>{% endif %}
|
||||||
|
|
||||||
<!-- Info View Additional Metadata -->
|
<!-- Info View Additional Metadata -->
|
||||||
<div class="info-meta">
|
<div class="info-meta">
|
||||||
@@ -280,8 +323,12 @@
|
|||||||
<a href="{{ url_for('checkpoint_detail', slug=img.slug) }}"
|
<a href="{{ url_for('checkpoint_detail', slug=img.slug) }}"
|
||||||
class="btn btn-sm btn-outline-light py-0 px-2"
|
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||||
onclick="event.stopPropagation()">Open</a>
|
onclick="event.stopPropagation()">Open</a>
|
||||||
|
{% elif img.category in ('presets', 'generator') %}
|
||||||
|
<a href="{{ url_for('preset_detail', slug=img.slug) }}"
|
||||||
|
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||||
|
onclick="event.stopPropagation()">Preset</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('generator') }}?{{ img.category[:-1] }}={{ img.slug }}"
|
<a href="{{ url_for('generator') }}?preset={{ img.slug }}"
|
||||||
class="btn btn-sm btn-outline-light py-0 px-2"
|
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||||
onclick="event.stopPropagation()">Generator</a>
|
onclick="event.stopPropagation()">Generator</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -376,6 +423,12 @@
|
|||||||
<div class="meta-grid" id="metaGrid"></div>
|
<div class="meta-grid" id="metaGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cross-reference entity chips (from sidecar) -->
|
||||||
|
<div class="mb-3 d-none" id="xrefRow">
|
||||||
|
<label class="form-label fw-semibold mb-1">Used Entities</label>
|
||||||
|
<div class="d-flex flex-wrap gap-1" id="xrefContainer"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="noMetaMsg" class="d-none">
|
<div id="noMetaMsg" class="d-none">
|
||||||
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
|
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,7 +462,31 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<style>
|
||||||
|
.fav-badge { position: absolute; top: 4px; left: 4px; font-size: 1.2rem; color: #ffc107; cursor: pointer; text-shadow: 0 0 3px rgba(0,0,0,0.7); z-index: 2; }
|
||||||
|
.fav-badge.fav-off { color: rgba(255,255,255,0.5); }
|
||||||
|
.fav-badge:hover { transform: scale(1.2); }
|
||||||
|
</style>
|
||||||
<script>
|
<script>
|
||||||
|
async function toggleImageFavourite(path, el) {
|
||||||
|
const resp = await fetch('/gallery/image/favourite', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({path})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
el.innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
el.classList.toggle('fav-off', !data.is_favourite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function toggleImageNsfw(path, el) {
|
||||||
|
const resp = await fetch('/gallery/image/nsfw', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({path})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) location.reload();
|
||||||
|
}
|
||||||
// ---- Prompt modal ----
|
// ---- Prompt modal ----
|
||||||
let promptModal;
|
let promptModal;
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -473,15 +550,55 @@ async function showPrompt(imgPath, name, category, slug) {
|
|||||||
document.getElementById('noMetaMsg').classList.toggle('d-none',
|
document.getElementById('noMetaMsg').classList.toggle('d-none',
|
||||||
!!(data.positive || loras.length || hasParams));
|
!!(data.positive || loras.length || hasParams));
|
||||||
|
|
||||||
|
// Cross-reference entity chips (from sidecar)
|
||||||
|
const xrefContainer = document.getElementById('xrefContainer');
|
||||||
|
const xrefRow = document.getElementById('xrefRow');
|
||||||
|
xrefContainer.innerHTML = '';
|
||||||
|
if (data.sidecar) {
|
||||||
|
const sc = data.sidecar;
|
||||||
|
const xrefColors = {
|
||||||
|
character: 'primary', outfit: 'success', action: 'danger',
|
||||||
|
style: 'warning', scene: 'info', detailer: 'secondary',
|
||||||
|
look: 'primary', preset: 'purple'
|
||||||
|
};
|
||||||
|
for (const [key, sidecarKey] of [
|
||||||
|
['character', 'character_slug'], ['outfit', 'outfit_slug'],
|
||||||
|
['action', 'action_slug'], ['style', 'style_slug'],
|
||||||
|
['scene', 'scene_slug'], ['detailer', 'detailer_slug'],
|
||||||
|
['look', 'look_slug'], ['preset', 'preset_slug']
|
||||||
|
]) {
|
||||||
|
const val = sc[sidecarKey];
|
||||||
|
if (!val) continue;
|
||||||
|
const chip = document.createElement('a');
|
||||||
|
chip.className = `badge bg-${xrefColors[key] || 'secondary'} text-decoration-none`;
|
||||||
|
chip.href = `/gallery?xref_category=${key}&xref_slug=${encodeURIComponent(val)}`;
|
||||||
|
chip.textContent = `${key}: ${sc[sidecarKey.replace('_slug', '_name')] || val}`;
|
||||||
|
chip.title = `Show all images using this ${key}`;
|
||||||
|
xrefContainer.appendChild(chip);
|
||||||
|
}
|
||||||
|
xrefRow.classList.toggle('d-none', xrefContainer.children.length === 0);
|
||||||
|
} else {
|
||||||
|
xrefRow.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
// Generator link
|
// Generator link
|
||||||
const genUrl = category === 'characters'
|
let genUrl, genLabel;
|
||||||
? `/character/${slug}`
|
if (category === 'characters') {
|
||||||
: category === 'checkpoints'
|
genUrl = `/character/${slug}`;
|
||||||
? `/checkpoint/${slug}`
|
genLabel = 'Open';
|
||||||
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
|
} else if (category === 'checkpoints') {
|
||||||
|
genUrl = `/checkpoint/${slug}`;
|
||||||
|
genLabel = 'Open';
|
||||||
|
} else if (category === 'presets' || category === 'generator') {
|
||||||
|
genUrl = `/generator?preset=${encodeURIComponent(slug)}`;
|
||||||
|
genLabel = 'Open in Generator';
|
||||||
|
} else {
|
||||||
|
genUrl = `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
|
||||||
|
genLabel = 'Open in Generator';
|
||||||
|
}
|
||||||
const genBtn = document.getElementById('openGeneratorBtn');
|
const genBtn = document.getElementById('openGeneratorBtn');
|
||||||
genBtn.href = genUrl;
|
genBtn.href = genUrl;
|
||||||
genBtn.textContent = (category === 'characters' || category === 'checkpoints') ? 'Open' : 'Open in Generator';
|
genBtn.textContent = genLabel;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('promptPositive').value = 'Error loading metadata.';
|
document.getElementById('promptPositive').value = 'Error loading metadata.';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,15 +6,15 @@
|
|||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<div id="progress-container" class="mb-3 d-none">
|
<div id="progress-container" class="mb-3 d-none">
|
||||||
<label id="progress-label" class="form-label">Generating...</label>
|
<label id="progress-label" class="form-label">Generating...</label>
|
||||||
<div class="progress" role="progressbar" aria-label="Generation Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
<div class="progress" role="progressbar">
|
||||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
|
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-primary text-white">Generator Settings</div>
|
<div class="card-header bg-primary text-white">Generator</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="generator-form" action="{{ url_for('generator') }}" method="post">
|
<form id="generator-form" action="{{ url_for('generator_generate') }}" method="post">
|
||||||
|
|
||||||
<!-- Controls bar -->
|
<!-- Controls bar -->
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
|
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
|
||||||
@@ -34,184 +34,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Character -->
|
<!-- Preset selector -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label for="character" class="form-label mb-0">Character</label>
|
<label for="preset-select" class="form-label mb-0 fw-semibold">Preset</label>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-char-btn">Random</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-preset-btn">Random</button>
|
||||||
</div>
|
</div>
|
||||||
<select class="form-select" id="character" name="character" required>
|
<select class="form-select" id="preset-select" name="preset_slug" required>
|
||||||
<option value="" disabled {% if not selected_char %}selected{% endif %}>Select a character...</option>
|
<option value="" disabled {% if not preset_slug %}selected{% endif %}>Select a preset...</option>
|
||||||
{% for char in characters %}
|
{% for p in presets %}
|
||||||
<option value="{{ char.slug }}" {% if selected_char == char.slug %}selected{% endif %}>{{ char.name }}</option>
|
<option value="{{ p.slug }}" {% if preset_slug == p.slug %}selected{% endif %}>{{ p.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Checkpoint -->
|
<!-- Preset summary (populated via AJAX) -->
|
||||||
|
<div class="mb-3" id="preset-summary-container" style="display:none">
|
||||||
|
<label class="form-label mb-1 small fw-semibold text-muted">Preset Configuration</label>
|
||||||
|
<div class="d-flex flex-wrap gap-1" id="preset-summary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkpoint override -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label for="checkpoint" class="form-label mb-0">Checkpoint Model</label>
|
<label for="checkpoint" class="form-label mb-0">Checkpoint Override</label>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button>
|
||||||
</div>
|
</div>
|
||||||
<select class="form-select" id="checkpoint" name="checkpoint" required>
|
<select class="form-select form-select-sm" id="checkpoint" name="checkpoint">
|
||||||
|
<option value="">Use preset default</option>
|
||||||
{% for ckpt in checkpoints %}
|
{% for ckpt in checkpoints %}
|
||||||
<option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt }}</option>
|
<option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt.split('/')[-1] }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">Listing models from Illustrious/ folder</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mix & Match -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Mix & Match
|
|
||||||
<small class="text-muted fw-normal ms-1">— first checked per category applies its LoRA</small>
|
|
||||||
</label>
|
|
||||||
<div class="accordion" id="mixAccordion">
|
|
||||||
{% set mix_categories = [
|
|
||||||
('Actions', 'action', actions, 'action_slugs'),
|
|
||||||
('Outfits', 'outfit', outfits, 'outfit_slugs'),
|
|
||||||
('Scenes', 'scene', scenes, 'scene_slugs'),
|
|
||||||
('Styles', 'style', styles, 'style_slugs'),
|
|
||||||
('Detailers', 'detailer', detailers, 'detailer_slugs'),
|
|
||||||
] %}
|
|
||||||
{% for cat_label, cat_key, cat_items, field_name in mix_categories %}
|
|
||||||
<div class="accordion-item">
|
|
||||||
<h2 class="accordion-header">
|
|
||||||
<button class="accordion-button collapsed py-2" type="button"
|
|
||||||
data-bs-toggle="collapse" data-bs-target="#mix-{{ cat_key }}">
|
|
||||||
{{ cat_label }}
|
|
||||||
<span class="badge bg-secondary rounded-pill ms-2" id="badge-{{ cat_key }}">0</span>
|
|
||||||
<span class="badge bg-light text-secondary border ms-2 px-2 py-1"
|
|
||||||
style="cursor:pointer;font-size:.7rem;font-weight:normal"
|
|
||||||
onclick="event.stopPropagation(); randomizeCategory('{{ field_name }}', '{{ cat_key }}')">Random</span>
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="mix-{{ cat_key }}" class="accordion-collapse collapse">
|
|
||||||
<div class="accordion-body p-2">
|
|
||||||
<input type="text" class="form-control form-control-sm mb-2"
|
|
||||||
placeholder="Search {{ cat_label | lower }}..."
|
|
||||||
oninput="filterMixCategory(this, 'mixlist-{{ cat_key }}')">
|
|
||||||
<div id="mixlist-{{ cat_key }}" style="max-height:220px;overflow-y:auto;">
|
|
||||||
{% for item in cat_items %}
|
|
||||||
<label class="mix-item d-flex align-items-center gap-2 px-2 py-1 rounded"
|
|
||||||
data-name="{{ item.name | lower }}" style="cursor:pointer;">
|
|
||||||
<input type="checkbox" class="form-check-input flex-shrink-0"
|
|
||||||
name="{{ field_name }}" value="{{ item.slug }}"
|
|
||||||
onchange="updateMixBadge('{{ cat_key }}', '{{ field_name }}')">
|
|
||||||
{% if item.image_path %}
|
|
||||||
<img src="{{ url_for('static', filename='uploads/' + item.image_path) }}"
|
|
||||||
class="rounded flex-shrink-0" style="width:32px;height:32px;object-fit:cover">
|
|
||||||
{% else %}
|
|
||||||
<span class="rounded bg-light flex-shrink-0 d-inline-flex align-items-center justify-content-center text-muted"
|
|
||||||
style="width:32px;height:32px;font-size:9px;">N/A</span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="small text-truncate">{{ item.name }}</span>
|
|
||||||
</label>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-muted small p-2 mb-0">No {{ cat_label | lower }} found.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resolution -->
|
<!-- Resolution -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Resolution</label>
|
<label class="form-label">Resolution Override</label>
|
||||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||||
<button type="button" class="btn btn-sm btn-secondary preset-btn" data-w="1024" data-h="1024">1:1</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="" data-h="">Preset default</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1152" data-h="896">4:3 L</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1024" data-h="1024">1:1</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="896" data-h="1152">4:3 P</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1152" data-h="896">4:3 L</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1344" data-h="768">16:9 L</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="896" data-h="1152">4:3 P</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1344">16:9 P</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1344" data-h="768">16:9 L</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1280" data-h="800">16:10 L</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="768" data-h="1344">16:9 P</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="800" data-h="1280">16:10 P</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1280" data-h="800">16:10 L</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1792" data-h="768">21:9 L</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="800" data-h="1280">16:10 P</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1792">21:9 P</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<label class="form-label mb-0 small fw-semibold">W</label>
|
<label class="form-label mb-0 small fw-semibold">W</label>
|
||||||
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
|
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
|
||||||
value="1024" min="64" max="4096" step="64" style="width:88px">
|
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
|
||||||
<span class="text-muted">×</span>
|
<span class="text-muted">×</span>
|
||||||
<label class="form-label mb-0 small fw-semibold">H</label>
|
<label class="form-label mb-0 small fw-semibold">H</label>
|
||||||
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
|
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
|
||||||
value="1024" min="64" max="4096" step="64" style="width:88px">
|
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Prompt Preview -->
|
<!-- Extra prompts -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<label for="extra_positive" class="form-label">Extra Positive Prompt</label>
|
||||||
<label class="form-label mb-0">Prompt Preview</label>
|
<textarea class="form-control form-control-sm" id="extra_positive" name="extra_positive" rows="2"
|
||||||
<div class="d-flex gap-1">
|
placeholder="Additional tags appended to the preset prompt"></textarea>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="build-preview-btn">Build</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tag-widget-container d-none" id="prompt-tags"></div>
|
|
||||||
<textarea class="form-control form-control-sm font-monospace" id="prompt-preview"
|
|
||||||
name="override_prompt" rows="5"
|
|
||||||
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
|
|
||||||
<div class="form-text" id="preview-status"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ADetailer Prompt Previews -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label mb-0">Face Detailer Prompt</label>
|
|
||||||
<div class="tag-widget-container d-none" id="face-tags"></div>
|
|
||||||
<textarea class="form-control form-control-sm font-monospace" id="face-preview"
|
|
||||||
name="override_face_prompt" rows="2"
|
|
||||||
placeholder="Auto-populated on Build — edit to override face detailer prompt."></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label mb-0">Hand Detailer Prompt</label>
|
<label for="extra_negative" class="form-label">Extra Negative Prompt</label>
|
||||||
<div class="tag-widget-container d-none" id="hand-tags"></div>
|
<textarea class="form-control form-control-sm" id="extra_negative" name="extra_negative" rows="2"
|
||||||
<textarea class="form-control form-control-sm font-monospace" id="hand-preview"
|
placeholder="Additional negative tags"></textarea>
|
||||||
name="override_hand_prompt" rows="2"
|
|
||||||
placeholder="Auto-populated on Build — edit to override hand detailer prompt."></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Additional prompts -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
|
|
||||||
<textarea class="form-control" id="positive_prompt" name="positive_prompt" rows="2" placeholder="e.g. sitting in a cafe, drinking coffee, daylight"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="negative_prompt" class="form-label">Additional Negative Prompt</label>
|
|
||||||
<textarea class="form-control" id="negative_prompt" name="negative_prompt" rows="2" placeholder="e.g. bad hands, extra digits"></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header bg-dark text-white">Result</div>
|
<div class="card-header bg-dark text-white">Result</div>
|
||||||
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container">
|
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container">
|
||||||
{% if generated_image %}
|
|
||||||
<div class="img-container w-100 h-100">
|
|
||||||
<img src="{{ url_for('static', filename='uploads/' + generated_image) }}" alt="Generated Result" class="img-fluid w-100" id="result-img">
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center text-muted" id="placeholder-text">
|
<div class="text-center text-muted" id="placeholder-text">
|
||||||
<p>Select settings and click Generate</p>
|
<p>Select a preset and click Generate</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="img-container w-100 h-100 d-none">
|
<div class="img-container w-100 h-100 d-none">
|
||||||
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
|
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-none" id="result-footer">
|
<div class="card-footer d-none" id="result-footer">
|
||||||
<small class="text-muted">Saved to character gallery</small>
|
<small class="text-muted">Saved to generator gallery</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,297 +131,194 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
// --- Filtering ---
|
// --- Preset summary ---
|
||||||
function filterMixCategory(input, listId) {
|
async function loadPresetInfo(slug) {
|
||||||
const query = input.value.toLowerCase();
|
const container = document.getElementById('preset-summary-container');
|
||||||
document.querySelectorAll(`#${listId} .mix-item`).forEach(el => {
|
const summary = document.getElementById('preset-summary');
|
||||||
el.style.display = el.dataset.name.includes(query) ? '' : 'none';
|
if (!slug) { container.style.display = 'none'; return; }
|
||||||
});
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/generator/preset_info?slug=${encodeURIComponent(slug)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
summary.innerHTML = '';
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
character: 'primary', outfit: 'success', action: 'danger',
|
||||||
|
style: 'warning', scene: 'info', detailer: 'secondary',
|
||||||
|
look: 'primary', checkpoint: 'dark', resolution: 'light text-dark'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(data)) {
|
||||||
|
if (val === null) continue;
|
||||||
|
const color = colors[key] || 'secondary';
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = `badge bg-${color} me-1 mb-1`;
|
||||||
|
chip.innerHTML = `<small class="opacity-75">${key}:</small> ${val}`;
|
||||||
|
summary.appendChild(chip);
|
||||||
}
|
}
|
||||||
|
container.style.display = '';
|
||||||
function updateMixBadge(key, fieldName) {
|
} catch (e) {
|
||||||
const count = document.querySelectorAll(`input[name="${fieldName}"]:checked`).length;
|
container.style.display = 'none';
|
||||||
const badge = document.getElementById(`badge-${key}`);
|
|
||||||
badge.textContent = count;
|
|
||||||
badge.className = count > 0
|
|
||||||
? 'badge bg-primary rounded-pill ms-2'
|
|
||||||
: 'badge bg-secondary rounded-pill ms-2';
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Randomizers (global so inline onclick can call them) ---
|
// --- Resolution presets ---
|
||||||
function randomizeCategory(fieldName, catKey) {
|
document.querySelectorAll('.res-btn').forEach(btn => {
|
||||||
const cbs = Array.from(document.querySelectorAll(`input[name="${fieldName}"]`));
|
|
||||||
cbs.forEach(cb => cb.checked = false);
|
|
||||||
if (cbs.length) cbs[Math.floor(Math.random() * cbs.length)].checked = true;
|
|
||||||
updateMixBadge(catKey, fieldName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyLuckyDip() {
|
|
||||||
const charOpts = Array.from(document.getElementById('character').options).filter(o => o.value);
|
|
||||||
if (charOpts.length)
|
|
||||||
document.getElementById('character').value = charOpts[Math.floor(Math.random() * charOpts.length)].value;
|
|
||||||
|
|
||||||
const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
|
||||||
if (ckptOpts.length)
|
|
||||||
document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
|
|
||||||
|
|
||||||
const presets = Array.from(document.querySelectorAll('.preset-btn'));
|
|
||||||
if (presets.length) presets[Math.floor(Math.random() * presets.length)].click();
|
|
||||||
|
|
||||||
[['action_slugs', 'action'], ['outfit_slugs', 'outfit'], ['scene_slugs', 'scene'],
|
|
||||||
['style_slugs', 'style'], ['detailer_slugs', 'detailer']].forEach(([field, key]) => {
|
|
||||||
randomizeCategory(field, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTagWidgets('prompt-tags', 'prompt-preview');
|
|
||||||
clearTagWidgets('face-tags', 'face-preview');
|
|
||||||
clearTagWidgets('hand-tags', 'hand-preview');
|
|
||||||
document.getElementById('preview-status').textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Resolution presets ---
|
|
||||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
document.getElementById('res-width').value = btn.dataset.w;
|
document.getElementById('res-width').value = btn.dataset.w;
|
||||||
document.getElementById('res-height').value = btn.dataset.h;
|
document.getElementById('res-height').value = btn.dataset.h;
|
||||||
document.querySelectorAll('.preset-btn').forEach(b => {
|
document.querySelectorAll('.res-btn').forEach(b => {
|
||||||
b.classList.remove('btn-secondary');
|
b.classList.remove('btn-secondary');
|
||||||
b.classList.add('btn-outline-secondary');
|
b.classList.add('btn-outline-secondary');
|
||||||
});
|
});
|
||||||
btn.classList.remove('btn-outline-secondary');
|
btn.classList.remove('btn-outline-secondary');
|
||||||
btn.classList.add('btn-secondary');
|
btn.classList.add('btn-secondary');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
['res-width', 'res-height'].forEach(id => {
|
// Deselect res presets when manual input changes
|
||||||
|
['res-width', 'res-height'].forEach(id => {
|
||||||
document.getElementById(id).addEventListener('input', () => {
|
document.getElementById(id).addEventListener('input', () => {
|
||||||
document.querySelectorAll('.preset-btn').forEach(b => {
|
document.querySelectorAll('.res-btn').forEach(b => {
|
||||||
b.classList.remove('btn-secondary');
|
b.classList.remove('btn-secondary');
|
||||||
b.classList.add('btn-outline-secondary');
|
b.classList.add('btn-outline-secondary');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Tag Widget System ---
|
// --- Seed ---
|
||||||
function populateTagWidgets(containerId, textareaId, promptStr) {
|
document.getElementById('seed-clear-btn').addEventListener('click', () => {
|
||||||
const container = document.getElementById(containerId);
|
document.getElementById('seed-input').value = '';
|
||||||
const textarea = document.getElementById(textareaId);
|
});
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
if (!promptStr || !promptStr.trim()) {
|
// --- Random buttons ---
|
||||||
container.classList.add('d-none');
|
document.getElementById('random-preset-btn').addEventListener('click', () => {
|
||||||
return;
|
const opts = Array.from(document.getElementById('preset-select').options).filter(o => o.value);
|
||||||
|
if (opts.length) {
|
||||||
|
const pick = opts[Math.floor(Math.random() * opts.length)];
|
||||||
|
document.getElementById('preset-select').value = pick.value;
|
||||||
|
loadPresetInfo(pick.value);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const tags = promptStr.split(',').map(t => t.trim()).filter(Boolean);
|
document.getElementById('random-ckpt-btn').addEventListener('click', () => {
|
||||||
tags.forEach(tag => {
|
const opts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
||||||
const el = document.createElement('span');
|
if (opts.length) {
|
||||||
el.className = 'tag-widget active';
|
document.getElementById('checkpoint').value = opts[Math.floor(Math.random() * opts.length)].value;
|
||||||
el.textContent = tag;
|
|
||||||
el.dataset.tag = tag;
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
el.classList.toggle('active');
|
|
||||||
el.classList.toggle('inactive');
|
|
||||||
rebuildFromTags(containerId, textareaId);
|
|
||||||
});
|
|
||||||
container.appendChild(el);
|
|
||||||
});
|
|
||||||
container.classList.remove('d-none');
|
|
||||||
textarea.classList.add('d-none');
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function rebuildFromTags(containerId, textareaId) {
|
// --- Lucky Dip ---
|
||||||
const container = document.getElementById(containerId);
|
function applyLuckyDip() {
|
||||||
const textarea = document.getElementById(textareaId);
|
document.getElementById('random-preset-btn').click();
|
||||||
const activeTags = Array.from(container.querySelectorAll('.tag-widget.active'))
|
const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
||||||
.map(el => el.dataset.tag);
|
if (ckptOpts.length)
|
||||||
textarea.value = activeTags.join(', ');
|
document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
|
||||||
|
const resBtns = Array.from(document.querySelectorAll('.res-btn'));
|
||||||
|
if (resBtns.length) resBtns[Math.floor(Math.random() * resBtns.length)].click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Preset select change ---
|
||||||
|
document.getElementById('preset-select').addEventListener('change', (e) => {
|
||||||
|
loadPresetInfo(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Generation loop ---
|
||||||
|
let stopRequested = false;
|
||||||
|
|
||||||
|
async function waitForJob(jobId) {
|
||||||
|
while (true) {
|
||||||
|
const res = await fetch(`/api/queue/${jobId}/status`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'done') return data;
|
||||||
|
if (data.status === 'failed') throw new Error(data.error || 'Generation failed');
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearTagWidgets(containerId, textareaId) {
|
function setGeneratingState(active) {
|
||||||
const container = document.getElementById(containerId);
|
document.getElementById('generate-btn').disabled = active;
|
||||||
const textarea = document.getElementById(textareaId);
|
document.getElementById('endless-btn').classList.toggle('d-none', active);
|
||||||
container.innerHTML = '';
|
document.getElementById('stop-btn').classList.toggle('d-none', !active);
|
||||||
container.classList.add('d-none');
|
document.getElementById('progress-container').classList.toggle('d-none', !active);
|
||||||
textarea.classList.remove('d-none');
|
}
|
||||||
textarea.value = '';
|
|
||||||
|
function updateSeedFromResult(result) {
|
||||||
|
if (result && result.result && result.result.seed != null) {
|
||||||
|
document.getElementById('seed-input').value = result.result.seed;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Prompt preview ---
|
async function runOne(label) {
|
||||||
async function buildPromptPreview() {
|
|
||||||
const charVal = document.getElementById('character').value;
|
|
||||||
const status = document.getElementById('preview-status');
|
|
||||||
if (!charVal) { status.textContent = 'Select a character first.'; return; }
|
|
||||||
|
|
||||||
status.textContent = 'Building...';
|
|
||||||
const formData = new FormData(document.getElementById('generator-form'));
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/generator/preview_prompt', { method: 'POST', body: formData });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.error) {
|
|
||||||
status.textContent = 'Error: ' + data.error;
|
|
||||||
} else {
|
|
||||||
document.getElementById('prompt-preview').value = data.prompt;
|
|
||||||
document.getElementById('face-preview').value = data.face || '';
|
|
||||||
document.getElementById('hand-preview').value = data.hand || '';
|
|
||||||
populateTagWidgets('prompt-tags', 'prompt-preview', data.prompt);
|
|
||||||
populateTagWidgets('face-tags', 'face-preview', data.face || '');
|
|
||||||
populateTagWidgets('hand-tags', 'hand-preview', data.hand || '');
|
|
||||||
status.textContent = 'Click tags to toggle — Clear to reset.';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
status.textContent = 'Request failed.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
|
|
||||||
document.getElementById('clear-preview-btn').addEventListener('click', () => {
|
|
||||||
clearTagWidgets('prompt-tags', 'prompt-preview');
|
|
||||||
clearTagWidgets('face-tags', 'face-preview');
|
|
||||||
clearTagWidgets('hand-tags', 'hand-preview');
|
|
||||||
document.getElementById('preview-status').textContent = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Main generation logic ---
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const form = document.getElementById('generator-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressCont = document.getElementById('progress-container');
|
|
||||||
const progressLbl = document.getElementById('progress-label');
|
|
||||||
const generateBtn = document.getElementById('generate-btn');
|
|
||||||
const endlessBtn = document.getElementById('endless-btn');
|
|
||||||
const stopBtn = document.getElementById('stop-btn');
|
|
||||||
const numInput = document.getElementById('num-images');
|
|
||||||
const resultImg = document.getElementById('result-img');
|
|
||||||
const placeholder = document.getElementById('placeholder-text');
|
|
||||||
const resultFooter = document.getElementById('result-footer');
|
|
||||||
|
|
||||||
let currentJobId = null;
|
|
||||||
let stopRequested = false;
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') progressLbl.textContent = 'Generating…';
|
|
||||||
else progressLbl.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setGeneratingState(active) {
|
|
||||||
generateBtn.disabled = active;
|
|
||||||
endlessBtn.disabled = active;
|
|
||||||
stopBtn.classList.toggle('d-none', !active);
|
|
||||||
if (!active) progressCont.classList.add('d-none');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runOne(label) {
|
|
||||||
if (document.getElementById('lucky-dip').checked) applyLuckyDip();
|
if (document.getElementById('lucky-dip').checked) applyLuckyDip();
|
||||||
|
|
||||||
progressCont.classList.remove('d-none');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
progressBar.style.width = '100%';
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
progressBar.textContent = label;
|
||||||
progressLbl.textContent = label;
|
document.getElementById('progress-label').textContent = label;
|
||||||
|
|
||||||
const fd = new FormData(form);
|
const form = document.getElementById('generator-form');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
const resp = await fetch(form.action, {
|
const res = await fetch(form.action, {
|
||||||
method: 'POST', body: fd,
|
method: 'POST',
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||||
|
body: formData,
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await res.json();
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
currentJobId = data.job_id;
|
const result = await waitForJob(data.job_id);
|
||||||
progressLbl.textContent = 'Queued…';
|
|
||||||
|
|
||||||
const jobResult = await waitForJob(currentJobId);
|
if (result.result && result.result.image_url) {
|
||||||
currentJobId = null;
|
const img = document.getElementById('result-img');
|
||||||
|
img.src = result.result.image_url + '?t=' + Date.now();
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
img.parentElement.classList.remove('d-none');
|
||||||
resultImg.src = jobResult.result.image_url;
|
document.getElementById('placeholder-text')?.classList.add('d-none');
|
||||||
resultImg.parentElement.classList.remove('d-none');
|
document.getElementById('result-footer').classList.remove('d-none');
|
||||||
if (placeholder) placeholder.classList.add('d-none');
|
|
||||||
resultFooter.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
updateSeedFromResult(jobResult.result);
|
|
||||||
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLoop(endless) {
|
updateSeedFromResult(result);
|
||||||
const total = endless ? Infinity : (parseInt(numInput.value) || 1);
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLoop(endless) {
|
||||||
stopRequested = false;
|
stopRequested = false;
|
||||||
setGeneratingState(true);
|
setGeneratingState(true);
|
||||||
let n = 0;
|
|
||||||
|
const total = endless ? Infinity : parseInt(document.getElementById('num-images').value) || 1;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!stopRequested && n < total) {
|
while (i < total && !stopRequested) {
|
||||||
n++;
|
i++;
|
||||||
const lbl = endless ? `Generating #${n} (endless)...`
|
const label = endless
|
||||||
: total === 1 ? 'Starting...'
|
? `Generating (endless) #${i}...`
|
||||||
: `Generating ${n} / ${total}...`;
|
: total > 1
|
||||||
await runOne(lbl);
|
? `Generating ${i} / ${total}...`
|
||||||
|
: 'Generating...';
|
||||||
|
await runOne(label);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
console.error(err);
|
alert('Generation error: ' + e.message);
|
||||||
alert('Generation failed: ' + err.message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setGeneratingState(false);
|
setGeneratingState(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', (e) => { e.preventDefault(); runLoop(false); });
|
// --- Event listeners ---
|
||||||
endlessBtn.addEventListener('click', () => runLoop(true));
|
document.getElementById('generator-form').addEventListener('submit', (e) => {
|
||||||
stopBtn.addEventListener('click', () => {
|
e.preventDefault();
|
||||||
stopRequested = true;
|
runLoop(false);
|
||||||
progressLbl.textContent = 'Stopping after current image...';
|
});
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('random-char-btn').addEventListener('click', () => {
|
document.getElementById('endless-btn').addEventListener('click', () => runLoop(true));
|
||||||
const opts = Array.from(document.getElementById('character').options).filter(o => o.value);
|
document.getElementById('stop-btn').addEventListener('click', () => { stopRequested = true; });
|
||||||
if (opts.length) {
|
|
||||||
document.getElementById('character').value = opts[Math.floor(Math.random() * opts.length)].value;
|
|
||||||
buildPromptPreview();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('random-ckpt-btn').addEventListener('click', () => {
|
// --- Init: load preset info if pre-selected ---
|
||||||
const opts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (opts.length)
|
const presetSelect = document.getElementById('preset-select');
|
||||||
document.getElementById('checkpoint').value = opts[Math.floor(Math.random() * opts.length)].value;
|
if (presetSelect.value) loadPresetInfo(presetSelect.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('character').addEventListener('change', buildPromptPreview);
|
|
||||||
|
|
||||||
// Pre-populate from gallery URL params (?action=slug, ?outfit=slug, etc.)
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const catMap = {
|
|
||||||
action: { field: 'action_slugs', catKey: 'action' },
|
|
||||||
outfit: { field: 'outfit_slugs', catKey: 'outfit' },
|
|
||||||
scene: { field: 'scene_slugs', catKey: 'scene' },
|
|
||||||
style: { field: 'style_slugs', catKey: 'style' },
|
|
||||||
detailer: { field: 'detailer_slugs', catKey: 'detailer' },
|
|
||||||
};
|
|
||||||
let preselected = false;
|
|
||||||
for (const [param, { field, catKey }] of Object.entries(catMap)) {
|
|
||||||
const val = urlParams.get(param);
|
|
||||||
if (!val) continue;
|
|
||||||
const cb = document.querySelector(`input[name="${field}"][value="${CSS.escape(val)}"]`);
|
|
||||||
if (cb) {
|
|
||||||
cb.checked = true;
|
|
||||||
updateMixBadge(catKey, field);
|
|
||||||
// Expand the accordion panel
|
|
||||||
const panel = document.getElementById(`mix-${catKey}`);
|
|
||||||
if (panel) new bootstrap.Collapse(panel, { toggle: false }).show();
|
|
||||||
preselected = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (preselected) buildPromptPreview();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||||
<h2>Character Library</h2>
|
{{ library_toolbar(
|
||||||
<div class="d-flex gap-1 align-items-center">
|
title="Character",
|
||||||
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
|
category="characters",
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_url=url_for('create_character'),
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_label="Character",
|
||||||
<form action="{{ url_for('rescan') }}" method="post" class="d-contents">
|
has_batch_gen=true,
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan character files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
has_regen_all=true,
|
||||||
</form>
|
has_lora_create=false,
|
||||||
|
has_tags=true,
|
||||||
|
regen_tags_category="characters",
|
||||||
|
rescan_url=url_for('rescan'),
|
||||||
|
get_missing_url="/get_missing_characters",
|
||||||
|
clear_covers_url="/clear_all_covers",
|
||||||
|
generate_url_pattern="/character/{slug}/generate"
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||||
{% for char in characters %}
|
{% for char in characters %}
|
||||||
@@ -33,7 +51,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-center">{{ char.name }}</h5>
|
<h5 class="card-title text-center">
|
||||||
|
{% if char.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ char.name }}
|
||||||
|
{% if char.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h5>
|
||||||
<p class="card-text small text-center text-muted">
|
<p class="card-text small text-center text-muted">
|
||||||
{% set ns = namespace(parts=[]) %}
|
{% set ns = namespace(parts=[]) %}
|
||||||
{% for section_key in ['identity', 'defaults'] %}
|
{% for section_key in ['identity', 'defaults'] %}
|
||||||
@@ -58,12 +79,14 @@
|
|||||||
{{ ns.parts | join(', ') }}
|
{{ ns.parts | join(', ') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if char.data.lora.lora_name %}
|
<div class="card-footer d-flex justify-content-between align-items-center p-1">
|
||||||
|
{% if char.data.lora and char.data.lora.lora_name %}
|
||||||
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||||
<div class="card-footer text-center p-1">
|
<small class="text-muted text-truncate" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||||
<small class="text-muted" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
|
{% else %}<span></span>{% endif %}
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||||
|
data-category="characters" data-slug="{{ char.slug }}" data-name="{{ char.name | e }}">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -71,97 +94,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const batchBtn = document.getElementById('batch-generate-btn');
|
|
||||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
|
||||||
else nodeStatus.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBatch() {
|
|
||||||
const response = await fetch('/get_missing_characters');
|
|
||||||
const data = await response.json();
|
|
||||||
const missing = data.missing;
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
alert("No characters missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = true;
|
|
||||||
regenAllBtn.disabled = true;
|
|
||||||
|
|
||||||
// Phase 1: Queue all jobs upfront so the page can be navigated away from
|
|
||||||
const jobs = [];
|
|
||||||
for (const char of missing) {
|
|
||||||
try {
|
|
||||||
const genResp = await fetch(`/character/${char.slug}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({ action: 'replace' }),
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const genData = await genResp.json();
|
|
||||||
if (genData.job_id) jobs.push({ item: char, jobId: genData.job_id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to queue ${char.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Poll all jobs concurrently; update UI as each finishes
|
|
||||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
|
||||||
try {
|
|
||||||
const jobResult = await waitForJob(jobId);
|
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
|
||||||
const img = document.getElementById(`img-${item.slug}`);
|
|
||||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
|
||||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
|
||||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed for ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
batchBtn.disabled = false;
|
|
||||||
regenAllBtn.disabled = false;
|
|
||||||
alert(`Batch generation complete! ${jobs.length} images queued.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.addEventListener('click', async () => {
|
|
||||||
const response = await fetch('/get_missing_characters');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.missing.length === 0) {
|
|
||||||
alert("No characters missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Generate cover images for ${data.missing.length} characters?`)) return;
|
|
||||||
runBatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
regenAllBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm("This will unassign ALL current cover images and generate new ones for every character. Existing files will be kept on disk. Proceed?")) return;
|
|
||||||
|
|
||||||
const clearResp = await fetch('/clear_all_covers', { method: 'POST' });
|
|
||||||
if (clearResp.ok) {
|
|
||||||
// Update UI to show "No Image" for all
|
|
||||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
|
||||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
|
||||||
runBatch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -31,8 +31,13 @@
|
|||||||
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
|
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
|
||||||
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
|
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
|
||||||
<div class="vr mx-1 d-none d-lg-block"></div>
|
<div class="vr mx-1 d-none d-lg-block"></div>
|
||||||
|
<!-- Search -->
|
||||||
|
<form action="/search" method="get" class="d-flex" style="max-width:180px;">
|
||||||
|
<input type="text" name="q" class="form-control form-control-sm bg-dark text-light border-secondary" placeholder="Search..." aria-label="Search">
|
||||||
|
</form>
|
||||||
|
<div class="vr mx-1 d-none d-lg-block"></div>
|
||||||
<!-- Queue indicator -->
|
<!-- Queue indicator -->
|
||||||
<button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Generation Queue">
|
<button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Job Queue">
|
||||||
<span class="queue-icon">⏳</span>
|
<span class="queue-icon">⏳</span>
|
||||||
<span id="queue-count-badge" class="queue-badge d-none">0</span>
|
<span id="queue-count-badge" class="queue-badge d-none">0</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -93,13 +98,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generation Queue Modal -->
|
<!-- Job Queue Modal -->
|
||||||
<div class="modal fade" id="queueModal" tabindex="-1">
|
<div class="modal fade" id="queueModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">
|
<h5 class="modal-title">
|
||||||
Generation Queue
|
Job Queue
|
||||||
<span id="queue-modal-count" class="badge bg-secondary ms-2">0</span>
|
<span id="queue-modal-count" class="badge bg-secondary ms-2">0</span>
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
@@ -232,6 +237,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function regenerateTags(category, slug) {
|
||||||
|
const btn = document.getElementById('regenerate-tags-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const origText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating…';
|
||||||
|
fetch(`/api/${category}/${slug}/regenerate_tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
|
||||||
|
.then(({ok, data}) => {
|
||||||
|
if (ok && data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
alert('Regeneration failed: ' + err);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initJsonEditor(saveUrl) {
|
function initJsonEditor(saveUrl) {
|
||||||
const jsonModal = document.getElementById('jsonEditorModal');
|
const jsonModal = document.getElementById('jsonEditorModal');
|
||||||
if (!jsonModal) return;
|
if (!jsonModal) return;
|
||||||
@@ -533,7 +565,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// ---- Generation Queue UI ----
|
// ---- Job Queue UI ----
|
||||||
(function() {
|
(function() {
|
||||||
const badge = document.getElementById('queue-count-badge');
|
const badge = document.getElementById('queue-count-badge');
|
||||||
const modalCount = document.getElementById('queue-modal-count');
|
const modalCount = document.getElementById('queue-modal-count');
|
||||||
@@ -575,7 +607,7 @@
|
|||||||
queueBtn.title = `${pendingJobs.length} job(s) queued`;
|
queueBtn.title = `${pendingJobs.length} job(s) queued`;
|
||||||
} else {
|
} else {
|
||||||
queueBtn.classList.remove('queue-btn-generating');
|
queueBtn.classList.remove('queue-btn-generating');
|
||||||
queueBtn.title = 'Generation Queue';
|
queueBtn.title = 'Job Queue';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modal count
|
// Update modal count
|
||||||
@@ -616,6 +648,15 @@
|
|||||||
statusDot.className = `queue-status-dot queue-status-${job.status}`;
|
statusDot.className = `queue-status-dot queue-status-${job.status}`;
|
||||||
li.appendChild(statusDot);
|
li.appendChild(statusDot);
|
||||||
|
|
||||||
|
// Job type badge
|
||||||
|
if (job.job_type === 'llm') {
|
||||||
|
const typeBadge = document.createElement('span');
|
||||||
|
typeBadge.className = 'badge bg-info';
|
||||||
|
typeBadge.textContent = 'LLM';
|
||||||
|
typeBadge.style.fontSize = '0.6rem';
|
||||||
|
li.appendChild(typeBadge);
|
||||||
|
}
|
||||||
|
|
||||||
// Label
|
// Label
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.className = 'flex-grow-1 small';
|
label.className = 'flex-grow-1 small';
|
||||||
|
|||||||
@@ -15,20 +15,38 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Display Name</label>
|
<label for="name" class="form-label">Display Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" required>
|
<input type="text" class="form-control" id="name" name="name" value="{{ form_data.get('name', '') }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="character_id" class="form-label">Linked Character</label>
|
<label for="character_id" class="form-label">Linked Character</label>
|
||||||
<select class="form-select" id="character_id" name="character_id">
|
<select class="form-select" id="character_id" name="character_id">
|
||||||
<option value="">— None —</option>
|
<option value="">— None —</option>
|
||||||
{% for char in characters %}
|
{% for char in characters %}
|
||||||
<option value="{{ char.character_id }}">{{ char.name }}</option>
|
<option value="{{ char.character_id }}" {{ 'selected' if form_data.get('character_id') == char.character_id }}>{{ char.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
{% set tags = form_data.get('tags', {}) if form_data.get('tags') is mapping else {} %}
|
||||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
<div class="row">
|
||||||
<input type="text" class="form-control" id="tags" name="tags">
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="tag_origin_series" class="form-label">Origin Series</label>
|
||||||
|
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="tag_origin_type" class="form-label">Origin Type</label>
|
||||||
|
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
|
||||||
|
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
|
||||||
|
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw">
|
||||||
|
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,18 +61,18 @@
|
|||||||
<select class="form-select" id="lora_lora_name" name="lora_lora_name">
|
<select class="form-select" id="lora_lora_name" name="lora_lora_name">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{% for lora in loras %}
|
{% for lora in loras %}
|
||||||
<option value="{{ lora }}">{{ lora }}</option>
|
<option value="{{ lora }}" {{ 'selected' if form_data.get('lora_lora_name') == lora }}>{{ lora }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="lora_lora_weight" class="form-label">Weight</label>
|
<label for="lora_lora_weight" class="form-label">Weight</label>
|
||||||
<input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight" value="0.8">
|
<input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight" value="{{ form_data.get('lora_lora_weight', 0.8) }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label for="lora_lora_triggers" class="form-label">Triggers</label>
|
<label for="lora_lora_triggers" class="form-label">Triggers</label>
|
||||||
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers">
|
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ form_data.get('lora_lora_triggers', '') }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,11 +83,11 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="positive" class="form-label">Positive</label>
|
<label for="positive" class="form-label">Positive</label>
|
||||||
<textarea class="form-control" id="positive" name="positive" rows="3"></textarea>
|
<textarea class="form-control" id="positive" name="positive" rows="3">{{ form_data.get('positive', '') }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="negative" class="form-label">Negative</label>
|
<label for="negative" class="form-label">Negative</label>
|
||||||
<textarea class="form-control" id="negative" name="negative" rows="2"></textarea>
|
<textarea class="form-control" id="negative" name="negative" rows="2">{{ form_data.get('negative', '') }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,33 +138,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set tags = look.data.tags if look.data.tags is mapping else {} %}
|
||||||
|
{% if tags %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||||
<span>Tags</span>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
|
||||||
{% if preferences is not none %}
|
|
||||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
|
||||||
{% elif look.default_fields is not none %}
|
|
||||||
{% if 'special::tags' in look.default_fields %}checked{% endif %}
|
|
||||||
{% endif %}>
|
|
||||||
<label class="form-check-label text-white small {% if look.default_fields is not none and 'special::tags' in look.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% for tag in look.data.tags %}
|
{% if tags.origin_series %}<span class="badge bg-info">{{ tags.origin_series }}</span>{% endif %}
|
||||||
<span class="badge bg-secondary">{{ tag }}</span>
|
{% if tags.origin_type %}<span class="badge bg-primary">{{ tags.origin_type }}</span>{% endif %}
|
||||||
{% else %}
|
{% if look.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||||
<span class="text-muted">No tags</span>
|
{% if look.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-0">{{ look.name }}</h1>
|
<h1 class="mb-0">
|
||||||
|
{{ look.name }}
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/look/{{ look.slug }}/favourite" title="Toggle favourite">
|
||||||
|
<span style="font-size:1.2rem;">{% if look.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
{% if look.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h1>
|
||||||
{% if linked_character_ids %}
|
{% if linked_character_ids %}
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Linked to:
|
Linked to:
|
||||||
@@ -178,6 +175,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('looks', '{{ look.slug }}')">Regenerate Tags</button>
|
||||||
<button type="button" class="btn btn-accent" data-bs-toggle="modal" data-bs-target="#generateCharModal">
|
<button type="button" class="btn btn-accent" data-bs-toggle="modal" data-bs-target="#generateCharModal">
|
||||||
<i class="bi bi-person-plus"></i> Generate Character
|
<i class="bi bi-person-plus"></i> Generate Character
|
||||||
</button>
|
</button>
|
||||||
@@ -281,6 +279,16 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favourite toggle
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
const form = document.getElementById('generate-form');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
|||||||
@@ -39,9 +39,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-text">Associates this look with multiple characters for generation and LoRA suggestions.</div>
|
<div class="form-text">Associates this look with multiple characters for generation and LoRA suggestions.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
{% set tags = look.data.tags if look.data.tags is mapping else {} %}
|
||||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
<div class="row">
|
||||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ look.data.tags | join(', ') }}">
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="tag_origin_series" class="form-label">Origin Series</label>
|
||||||
|
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="tag_origin_type" class="form-label">Origin Type</label>
|
||||||
|
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
|
||||||
|
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
|
||||||
|
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if look.is_nsfw %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||||
<h2>Looks Library</h2>
|
{{ library_toolbar(
|
||||||
<div class="d-flex gap-1 align-items-center">
|
title="Look",
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
category="looks",
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_url=url_for('create_look'),
|
||||||
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents">
|
create_label="Look",
|
||||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new look entries from all LoRA files in the Looks folder"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_batch_gen=true,
|
||||||
</form>
|
has_regen_all=true,
|
||||||
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents">
|
has_lora_create=true,
|
||||||
<input type="hidden" name="overwrite" value="true">
|
bulk_create_url=url_for('bulk_create_looks_from_loras'),
|
||||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all look metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL look LoRAs, consuming significant API credits and overwriting ALL existing look metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_tags=true,
|
||||||
</form>
|
regen_tags_category="looks",
|
||||||
<a href="{{ url_for('create_look') }}" class="btn btn-sm btn-success">Create New Look</a>
|
rescan_url=url_for('rescan_looks'),
|
||||||
<form action="{{ url_for('rescan_looks') }}" method="post" class="d-contents">
|
get_missing_url="/get_missing_looks",
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan look files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
clear_covers_url="/clear_all_look_covers",
|
||||||
</form>
|
generate_url_pattern="/look/{slug}/generate"
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||||
{% for look in looks %}
|
{% for look in looks %}
|
||||||
@@ -43,7 +55,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-center">{{ look.name }}</h5>
|
<h5 class="card-title text-center">{% if look.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ look.name }}{% if look.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||||
{% if look.character_id %}
|
{% if look.character_id %}
|
||||||
<p class="card-text small text-center text-info">{{ look.character_id.replace('_', ' ').title() }}</p>
|
<p class="card-text small text-center text-info">{{ look.character_id.replace('_', ' ').title() }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -86,133 +98,11 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Handle highlight parameter
|
|
||||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||||
if (highlightSlug) {
|
if (highlightSlug) {
|
||||||
const card = document.getElementById(`card-${highlightSlug}`);
|
const card = document.getElementById(`card-${highlightSlug}`);
|
||||||
if (card) {
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const batchBtn = document.getElementById('batch-generate-btn');
|
|
||||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
|
||||||
const itemNameText = document.getElementById('current-item-name');
|
|
||||||
const stepProgressText = document.getElementById('current-step-progress');
|
|
||||||
|
|
||||||
let currentJobId = null;
|
|
||||||
let queuePollInterval = null;
|
|
||||||
|
|
||||||
async function updateCurrentJobLabel() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/queue');
|
|
||||||
const data = await resp.json();
|
|
||||||
const processingJob = data.jobs.find(j => j.status === 'processing');
|
|
||||||
if (processingJob) {
|
|
||||||
itemNameText.textContent = `Processing: ${processingJob.label}`;
|
|
||||||
} else {
|
|
||||||
itemNameText.textContent = '';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch queue:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
|
||||||
else nodeStatus.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBatch() {
|
|
||||||
const response = await fetch('/get_missing_looks');
|
|
||||||
const data = await response.json();
|
|
||||||
const missing = data.missing;
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
alert("No looks missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = true;
|
|
||||||
regenAllBtn.disabled = true;
|
|
||||||
|
|
||||||
// Phase 1: Queue all jobs upfront
|
|
||||||
|
|
||||||
const jobs = [];
|
|
||||||
for (const item of missing) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
const genResp = await fetch(`/look/${item.slug}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({ action: 'replace' }),
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const genData = await genResp.json();
|
|
||||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to queue ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Poll all concurrently
|
|
||||||
// Start polling queue for current job label
|
|
||||||
queuePollInterval = setInterval(updateCurrentJobLabel, 1000);
|
|
||||||
updateCurrentJobLabel(); // Initial update
|
|
||||||
|
|
||||||
let completed = 0;
|
|
||||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
|
||||||
try {
|
|
||||||
const jobResult = await waitForJob(jobId);
|
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
|
||||||
const img = document.getElementById(`img-${item.slug}`);
|
|
||||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
|
||||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
|
||||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed for ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Stop polling queue
|
|
||||||
if (queuePollInterval) {
|
|
||||||
clearInterval(queuePollInterval);
|
|
||||||
queuePollInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = false;
|
|
||||||
regenAllBtn.disabled = false;
|
|
||||||
alert(`Batch generation complete! ${jobs.length} look images processed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.addEventListener('click', async () => {
|
|
||||||
const response = await fetch('/get_missing_looks');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.missing.length === 0) { alert("No looks missing cover images."); return; }
|
|
||||||
if (!confirm(`Generate cover images for ${data.missing.length} looks?`)) return;
|
|
||||||
runBatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
regenAllBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm("This will unassign ALL current look cover images and generate new ones. Proceed?")) return;
|
|
||||||
const clearResp = await fetch('/clear_all_look_covers', { method: 'POST' });
|
|
||||||
if (clearResp.ok) {
|
|
||||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
|
||||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
|
||||||
runBatch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,23 +10,23 @@
|
|||||||
<form action="{{ url_for('create_outfit') }}" method="post">
|
<form action="{{ url_for('create_outfit') }}" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Outfit Name</label>
|
<label for="name" class="form-label">Outfit Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. French Maid" required>
|
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. French Maid" value="{{ form_data.get('name', '') }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
||||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. french_maid_01">
|
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. french_maid_01" value="{{ form_data.get('filename', '') }}">
|
||||||
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 form-check form-switch">
|
<div class="mb-3 form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
|
||||||
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3" id="prompt-group">
|
<div class="mb-3" id="prompt-group">
|
||||||
<label for="prompt" class="form-label">Description / Concept</label>
|
<label for="prompt" class="form-label">Description / Concept</label>
|
||||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the outfit's style, components, colors, and any special features. The AI will generate the full outfit profile based on this."></textarea>
|
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the outfit's style, components, colors, and any special features. The AI will generate the full outfit profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
|
||||||
<div class="form-text">Required when AI generation is enabled.</div>
|
<div class="form-text">Required when AI generation is enabled.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -100,33 +100,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set tags = outfit.data.tags if outfit.data.tags is mapping else {} %}
|
||||||
|
{% if tags %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||||
<span>Tags</span>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
|
||||||
{% if preferences is not none %}
|
|
||||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
|
||||||
{% elif outfit.default_fields is not none %}
|
|
||||||
{% if 'special::tags' in outfit.default_fields %}checked{% endif %}
|
|
||||||
{% endif %}>
|
|
||||||
<label class="form-check-label text-white small {% if outfit.default_fields is not none and 'special::tags' in outfit.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% for tag in outfit.data.tags %}
|
{% if tags.outfit_type %}<span class="badge bg-info">{{ tags.outfit_type }}</span>{% endif %}
|
||||||
<span class="badge bg-secondary">{{ tag }}</span>
|
{% if outfit.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||||
{% else %}
|
{% if outfit.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||||
<span class="text-muted">No tags</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-0">{{ outfit.name }}</h1>
|
<h1 class="mb-0">
|
||||||
|
{{ outfit.name }}
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/outfit/{{ outfit.slug }}/favourite" title="Toggle favourite">
|
||||||
|
<span style="font-size:1.2rem;">{% if outfit.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
{% if outfit.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h1>
|
||||||
<a href="{{ url_for('edit_outfit', slug=outfit.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
<a href="{{ url_for('edit_outfit', slug=outfit.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
||||||
<form action="{{ url_for('clone_outfit', slug=outfit.slug) }}" method="post" style="display: inline;">
|
<form action="{{ url_for('clone_outfit', slug=outfit.slug) }}" method="post" style="display: inline;">
|
||||||
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Outfit</button>
|
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Outfit</button>
|
||||||
@@ -134,6 +130,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('outfits', '{{ outfit.slug }}')">Regenerate Tags</button>
|
||||||
<a href="{{ url_for('transfer_resource', category='outfits', slug=outfit.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
<a href="{{ url_for('transfer_resource', category='outfits', slug=outfit.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||||
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,6 +277,16 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favourite toggle
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
const form = document.getElementById('generate-form');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
|||||||
@@ -22,9 +22,23 @@
|
|||||||
<label for="outfit_id" class="form-label">Outfit ID</label>
|
<label for="outfit_id" class="form-label">Outfit ID</label>
|
||||||
<input type="text" class="form-control" id="outfit_id" name="outfit_id" value="{{ outfit.outfit_id }}">
|
<input type="text" class="form-control" id="outfit_id" name="outfit_id" value="{{ outfit.outfit_id }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
{% set tags = outfit.data.tags if outfit.data.tags is mapping else {} %}
|
||||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
<div class="row">
|
||||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ outfit.data.tags | join(', ') }}">
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="tag_outfit_type" class="form-label">Outfit Type</label>
|
||||||
|
<select class="form-select" id="tag_outfit_type" name="tag_outfit_type">
|
||||||
|
{% for opt in ['', 'Formal', 'Casual', 'Swimsuit', 'Lingerie', 'Underwear', 'Nude', 'Cosplay', 'Uniform', 'Fantasy', 'Armor', 'Traditional'] %}
|
||||||
|
<option value="{{ opt }}" {% if tags.outfit_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if outfit.is_nsfw %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||||
<h2>Outfit Library</h2>
|
{{ library_toolbar(
|
||||||
<div class="d-flex gap-1 align-items-center">
|
title="Outfit",
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
category="outfits",
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_url=url_for('create_outfit'),
|
||||||
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents">
|
create_label="Outfit",
|
||||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new outfit entries from all LoRA files in the Clothing folder"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_batch_gen=true,
|
||||||
</form>
|
has_regen_all=true,
|
||||||
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents">
|
has_lora_create=true,
|
||||||
<input type="hidden" name="overwrite" value="true">
|
bulk_create_url=url_for('bulk_create_outfits_from_loras'),
|
||||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all outfit metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL outfit LoRAs, consuming significant API credits and overwriting ALL existing outfit metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_tags=true,
|
||||||
</form>
|
regen_tags_category="outfits",
|
||||||
<a href="{{ url_for('create_outfit') }}" class="btn btn-sm btn-success">Create New Outfit</a>
|
rescan_url=url_for('rescan_outfits'),
|
||||||
<form action="{{ url_for('rescan_outfits') }}" method="post" class="d-contents">
|
get_missing_url="/get_missing_outfits",
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan outfit files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
clear_covers_url="/clear_all_outfit_covers",
|
||||||
</form>
|
generate_url_pattern="/outfit/{slug}/generate"
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||||
{% for outfit in outfits %}
|
{% for outfit in outfits %}
|
||||||
@@ -43,7 +55,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-center">{{ outfit.name }}</h5>
|
<h5 class="card-title text-center">{% if outfit.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ outfit.name }}{% if outfit.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||||
<p class="card-text small text-center text-muted">
|
<p class="card-text small text-center text-muted">
|
||||||
{% set ns = namespace(parts=[]) %}
|
{% set ns = namespace(parts=[]) %}
|
||||||
{% if outfit.data.wardrobe is mapping %}
|
{% if outfit.data.wardrobe is mapping %}
|
||||||
@@ -83,111 +95,12 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Handle highlight parameter
|
// Handle highlight parameter
|
||||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||||
if (highlightSlug) {
|
if (highlightSlug) {
|
||||||
const card = document.getElementById(`card-${highlightSlug}`);
|
const card = document.getElementById(`card-${highlightSlug}`);
|
||||||
if (card) {
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const batchBtn = document.getElementById('batch-generate-btn');
|
|
||||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
|
||||||
const itemNameText = document.getElementById('current-item-name');
|
|
||||||
const stepProgressText = document.getElementById('current-step-progress');
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
|
||||||
else nodeStatus.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBatch() {
|
|
||||||
const response = await fetch('/get_missing_outfits');
|
|
||||||
const data = await response.json();
|
|
||||||
const missing = data.missing;
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
alert("No outfits missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = true;
|
|
||||||
regenAllBtn.disabled = true;
|
|
||||||
|
|
||||||
// Phase 1: Queue all jobs upfront
|
|
||||||
|
|
||||||
const jobs = [];
|
|
||||||
for (const item of missing) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
const genResp = await fetch(`/outfit/${item.slug}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const genData = await genResp.json();
|
|
||||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to queue ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Poll all concurrently
|
|
||||||
let currentItem = '';
|
|
||||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
|
||||||
currentItem = item.name;
|
|
||||||
itemNameText.textContent = `Processing: ${currentItem}`;
|
|
||||||
try {
|
|
||||||
const jobResult = await waitForJob(jobId);
|
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
|
||||||
const img = document.getElementById(`img-${item.slug}`);
|
|
||||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
|
||||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
|
||||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed for ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
batchBtn.disabled = false;
|
|
||||||
regenAllBtn.disabled = false;
|
|
||||||
alert(`Batch generation complete! ${jobs.length} outfit images processed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.addEventListener('click', async () => {
|
|
||||||
const response = await fetch('/get_missing_outfits');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.missing.length === 0) {
|
|
||||||
alert("No outfits missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Generate cover images for ${data.missing.length} outfits?`)) return;
|
|
||||||
runBatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
regenAllBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm("This will unassign ALL current outfit cover images and generate new ones. Proceed?")) return;
|
|
||||||
|
|
||||||
const clearResp = await fetch('/clear_all_outfit_covers', { method: 'POST' });
|
|
||||||
if (clearResp.ok) {
|
|
||||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
|
||||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
|
||||||
runBatch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
70
templates/partials/library_toolbar.html
Normal file
70
templates/partials/library_toolbar.html
Normal 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 %}
|
||||||
@@ -22,12 +22,12 @@
|
|||||||
<form action="{{ url_for('create_preset') }}" method="post">
|
<form action="{{ url_for('create_preset') }}" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Preset Name <span class="text-danger">*</span></label>
|
<label for="name" class="form-label">Preset Name <span class="text-danger">*</span></label>
|
||||||
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual">
|
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual" value="{{ form_data.get('name', '') }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
|
||||||
<label class="form-check-label" for="use_llm">Use AI to build preset from description</label>
|
<label class="form-check-label" for="use_llm">Use AI to build preset from description</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<textarea class="form-control" id="description" name="description" rows="5"
|
<textarea class="form-control" id="description" name="description" rows="5"
|
||||||
placeholder="Describe the kind of generations you want this preset to produce. Include the type of character, setting, mood, outfit style, etc.
|
placeholder="Describe the kind of generations you want this preset to produce. Include the type of character, setting, mood, outfit style, etc.
|
||||||
|
|
||||||
Example: A random character in a cheerful beach scene, wearing a swimsuit, with a random action pose. Always include identity and wardrobe fields. Randomise headwear and accessories. No style LoRA needed."></textarea>
|
Example: A random character in a cheerful beach scene, wearing a swimsuit, with a random action pose. Always include identity and wardrobe fields. Randomise headwear and accessories. No style LoRA needed.">{{ form_data.get('description', '') }}</textarea>
|
||||||
<div class="form-text">The AI will set entity IDs (specific, random, or none) and field toggles (on/off/random) based on your description.</div>
|
<div class="form-text">The AI will set entity IDs (specific, random, or none) and field toggles (on/off/random) based on your description.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<hr class="my-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<small class="fw-semibold">Suppress Wardrobe</small>
|
||||||
|
<div class="form-text mt-0">Strip all clothing/wardrobe prompts from generation</div>
|
||||||
|
</div>
|
||||||
|
{% set sw = act.get('suppress_wardrobe') %}
|
||||||
|
<select class="form-select form-select-sm" name="act_suppress_wardrobe" style="width:auto">
|
||||||
|
<option value="default" {% if sw is none %}selected{% endif %}>Action default</option>
|
||||||
|
<option value="true" {% if sw == true %}selected{% endif %}>Always</option>
|
||||||
|
<option value="false" {% if sw == false %}selected{% endif %}>Never</option>
|
||||||
|
<option value="random" {% if sw == 'random' %}selected{% endif %}>Random</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<form action="{{ url_for('create_scene') }}" method="post">
|
<form action="{{ url_for('create_scene') }}" method="post">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="name" class="form-label fw-bold">Scene Name</label>
|
<label for="name" class="form-label fw-bold">Scene Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Luxury Bedroom" required>
|
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Luxury Bedroom" value="{{ form_data.get('name', '') }}" required>
|
||||||
<div class="form-text">The display name for the scene gallery.</div>
|
<div class="form-text">The display name for the scene gallery.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="filename" class="form-label fw-bold">Scene ID / Filename <small class="text-muted">(Optional)</small></label>
|
<label for="filename" class="form-label fw-bold">Scene ID / Filename <small class="text-muted">(Optional)</small></label>
|
||||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. luxury_bedroom">
|
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. luxury_bedroom" value="{{ form_data.get('filename', '') }}">
|
||||||
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,13 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-0">{{ scene.name }}</h1>
|
<h1 class="mb-0">
|
||||||
|
{{ scene.name }}
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/scene/{{ scene.slug }}/favourite" title="Toggle favourite">
|
||||||
|
<span style="font-size:1.2rem;">{% if scene.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
{% if scene.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h1>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<a href="{{ url_for('edit_scene', slug=scene.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Scene</a>
|
<a href="{{ url_for('edit_scene', slug=scene.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Scene</a>
|
||||||
<span class="text-muted">|</span>
|
<span class="text-muted">|</span>
|
||||||
@@ -127,6 +133,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('scenes', '{{ scene.slug }}')">Regenerate Tags</button>
|
||||||
<a href="{{ url_for('transfer_resource', category='scenes', slug=scene.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
<a href="{{ url_for('transfer_resource', category='scenes', slug=scene.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||||
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,6 +273,16 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favourite toggle
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
const form = document.getElementById('generate-form');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
|||||||
@@ -106,11 +106,23 @@
|
|||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-light"><strong>Tags</strong></div>
|
<div class="card-header bg-light"><strong>Tags</strong></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
{% set tags = scene.data.tags if scene.data.tags is mapping else {} %}
|
||||||
<label for="tags" class="form-label">Tags</label>
|
<div class="row">
|
||||||
<input type="text" class="form-control" id="tags" name="tags"
|
<div class="col-md-6 mb-3">
|
||||||
value="{{ scene.data.tags | join(', ') if scene.data.tags else '' }}">
|
<label for="tag_scene_type" class="form-label">Scene Type</label>
|
||||||
<div class="form-text">Comma-separated tags appended to every generation.</div>
|
<select class="form-select" id="tag_scene_type" name="tag_scene_type">
|
||||||
|
{% for opt in ['', 'Indoor', 'Outdoor', 'Fantasy', 'Urban', 'Nature', 'Abstract'] %}
|
||||||
|
<option value="{{ opt }}" {% if tags.scene_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if scene.is_nsfw %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
{{ library_toolbar(
|
||||||
<h2>Scene Library</h2>
|
title="Scene",
|
||||||
<div class="d-flex gap-1 align-items-center">
|
category="scenes",
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_url=url_for('create_scene'),
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_label="Scene",
|
||||||
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents">
|
has_batch_gen=true,
|
||||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new scene entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_regen_all=true,
|
||||||
</form>
|
has_lora_create=true,
|
||||||
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents">
|
bulk_create_url=url_for('bulk_create_scenes_from_loras'),
|
||||||
<input type="hidden" name="overwrite" value="true">
|
has_tags=true,
|
||||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all scene metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL scene LoRAs, consuming significant API credits and overwriting ALL existing scene metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
regen_tags_category="scenes",
|
||||||
</form>
|
rescan_url=url_for('rescan_scenes'),
|
||||||
<a href="{{ url_for('create_scene') }}" class="btn btn-sm btn-success">Create New Scene</a>
|
get_missing_url="/get_missing_scenes",
|
||||||
<form action="{{ url_for('rescan_scenes') }}" method="post" class="d-contents">
|
clear_covers_url="/clear_all_scene_covers",
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan scene files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
generate_url_pattern="/scene/{slug}/generate"
|
||||||
</form>
|
) }}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||||
{% for scene in scenes %}
|
{% for scene in scenes %}
|
||||||
@@ -40,7 +52,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-center">{{ scene.name }}</h5>
|
<h5 class="card-title text-center">{% if scene.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ scene.name }}{% if scene.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||||
<p class="card-text small text-center text-muted">
|
<p class="card-text small text-center text-muted">
|
||||||
{% set ns = namespace(parts=[]) %}
|
{% set ns = namespace(parts=[]) %}
|
||||||
{% if scene.data.scene is mapping %}
|
{% if scene.data.scene is mapping %}
|
||||||
@@ -80,110 +92,11 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Handle highlight parameter
|
|
||||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||||
if (highlightSlug) {
|
if (highlightSlug) {
|
||||||
const card = document.getElementById(`card-${highlightSlug}`);
|
const card = document.getElementById(`card-${highlightSlug}`);
|
||||||
if (card) {
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const batchBtn = document.getElementById('batch-generate-btn');
|
|
||||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
|
||||||
const sceneNameText = document.getElementById('current-scene-name');
|
|
||||||
const stepProgressText = document.getElementById('current-step-progress');
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
|
||||||
else nodeStatus.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBatch() {
|
|
||||||
const response = await fetch('/get_missing_scenes');
|
|
||||||
const data = await response.json();
|
|
||||||
const missing = data.missing;
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
alert("No scenes missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = true;
|
|
||||||
regenAllBtn.disabled = true;
|
|
||||||
|
|
||||||
// Phase 1: Queue all jobs upfront
|
|
||||||
|
|
||||||
const jobs = [];
|
|
||||||
for (const scene of missing) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
const genResp = await fetch(`/scene/${scene.slug}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({ action: 'replace', character_slug: '' }),
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const genData = await genResp.json();
|
|
||||||
if (genData.job_id) jobs.push({ item: scene, jobId: genData.job_id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to queue ${scene.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Poll all concurrently
|
|
||||||
let currentItem = '';
|
|
||||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
|
||||||
currentItem = item.name;
|
|
||||||
itemNameText.textContent = `Processing: ${currentItem}`;
|
|
||||||
try {
|
|
||||||
const jobResult = await waitForJob(jobId);
|
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
|
||||||
const img = document.getElementById(`img-${item.slug}`);
|
|
||||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
|
||||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
|
||||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed for ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
batchBtn.disabled = false;
|
|
||||||
alert(`Batch generation complete! ${jobs.length} scene images processed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.addEventListener('click', async () => {
|
|
||||||
const response = await fetch('/get_missing_scenes');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.missing.length === 0) {
|
|
||||||
alert("No scenes missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Generate cover images for ${data.missing.length} scenes?`)) return;
|
|
||||||
runBatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
regenAllBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm("This will unassign ALL current scene cover images and generate new ones. Proceed?")) return;
|
|
||||||
|
|
||||||
const clearResp = await fetch('/clear_all_scene_covers', { method: 'POST' });
|
|
||||||
if (clearResp.ok) {
|
|
||||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
|
||||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
|
||||||
runBatch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
126
templates/search.html
Normal file
126
templates/search.html
Normal 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">★</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;">★</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 %}
|
||||||
@@ -164,6 +164,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag Management -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark text-white">Tag Management</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Migrate Tags</h6>
|
||||||
|
<p class="text-muted small">Convert old list-format tags to new structured dict format across all resources.</p>
|
||||||
|
<button class="btn btn-warning" id="migrate-tags-btn" onclick="migrateTags()">Migrate Tags to New Format</button>
|
||||||
|
<span id="migrate-tags-status" class="ms-2"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Bulk Regenerate Tags</h6>
|
||||||
|
<p class="text-muted small">Use LLM to regenerate structured tags for all resources. This will overwrite existing tags.</p>
|
||||||
|
<button class="btn btn-danger" id="bulk-regen-btn" onclick="bulkRegenerateTags()">Regenerate All Tags (LLM)</button>
|
||||||
|
<div id="bulk-regen-progress" class="mt-2" style="display: none;">
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted" id="bulk-regen-status"></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -339,5 +365,59 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function migrateTags() {
|
||||||
|
const btn = document.getElementById('migrate-tags-btn');
|
||||||
|
const status = document.getElementById('migrate-tags-status');
|
||||||
|
if (!confirm('Convert all old list-format tags to new dict format?')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
status.textContent = 'Migrating...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/migrate_tags', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
status.textContent = data.success ? `Done! Migrated ${data.migrated} resources.` : `Error: ${data.error}`;
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = 'Failed: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkRegenerateTags() {
|
||||||
|
if (!confirm('Regenerate tags for ALL resources using the LLM? This may take a while and will overwrite existing tags.')) return;
|
||||||
|
const btn = document.getElementById('bulk-regen-btn');
|
||||||
|
const progress = document.getElementById('bulk-regen-progress');
|
||||||
|
const bar = progress.querySelector('.progress-bar');
|
||||||
|
const status = document.getElementById('bulk-regen-status');
|
||||||
|
btn.disabled = true;
|
||||||
|
progress.style.display = 'block';
|
||||||
|
|
||||||
|
const categories = ['characters', 'outfits', 'actions', 'styles', 'scenes', 'detailers', 'looks'];
|
||||||
|
// Fetch all slugs per category
|
||||||
|
let allItems = [];
|
||||||
|
for (const cat of categories) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/get_missing_${cat}`);
|
||||||
|
// This endpoint returns items missing covers, but we need ALL items.
|
||||||
|
// Instead, we'll use a simpler approach: fetch the index page data
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
// Use a simpler approach: call regenerate for each category via a bulk endpoint
|
||||||
|
status.textContent = 'Queuing regeneration for all resources...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/bulk_regenerate_tags', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
bar.style.width = '100%';
|
||||||
|
status.textContent = `Queued ${data.total} resources for regeneration. Check console for progress.`;
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.error}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = 'Failed: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<form action="{{ url_for('create_style') }}" method="post">
|
<form action="{{ url_for('create_style') }}" method="post">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="name" class="form-label fw-bold">Style Name</label>
|
<label for="name" class="form-label fw-bold">Style Name</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Sabu Style" required>
|
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Sabu Style" value="{{ form_data.get('name', '') }}" required>
|
||||||
<div class="form-text">The display name for the style gallery.</div>
|
<div class="form-text">The display name for the style gallery.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="filename" class="form-label fw-bold">Style ID / Filename <small class="text-muted">(Optional)</small></label>
|
<label for="filename" class="form-label fw-bold">Style ID / Filename <small class="text-muted">(Optional)</small></label>
|
||||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. sabu_01">
|
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. sabu_01" value="{{ form_data.get('filename', '') }}">
|
||||||
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,13 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-0">{{ style.name }}</h1>
|
<h1 class="mb-0">
|
||||||
|
{{ style.name }}
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/style/{{ style.slug }}/favourite" title="Toggle favourite">
|
||||||
|
<span style="font-size:1.2rem;">{% if style.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
{% if style.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||||
|
</h1>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<a href="{{ url_for('edit_style', slug=style.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Style</a>
|
<a href="{{ url_for('edit_style', slug=style.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Style</a>
|
||||||
<span class="text-muted">|</span>
|
<span class="text-muted">|</span>
|
||||||
@@ -127,6 +133,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('styles', '{{ style.slug }}')">Regenerate Tags</button>
|
||||||
<a href="{{ url_for('transfer_resource', category='styles', slug=style.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
<a href="{{ url_for('transfer_resource', category='styles', slug=style.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||||
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,6 +265,16 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favourite toggle
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
const form = document.getElementById('generate-form');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
|||||||
@@ -81,6 +81,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light"><strong>Tags</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% set tags = style.data.tags if style.data.tags is mapping else {} %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="tag_style_type" class="form-label">Style Type</label>
|
||||||
|
<select class="form-select" id="tag_style_type" name="tag_style_type">
|
||||||
|
{% for opt in ['', 'Anime', 'Realistic', 'Western', 'Artistic', 'Sketch', 'Watercolor', 'Digital', 'Pixel Art'] %}
|
||||||
|
<option value="{{ opt }}" {% if tags.style_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if style.is_nsfw %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
<a href="{{ url_for('style_detail', slug=style.slug) }}" class="btn btn-secondary">Cancel</a>
|
<a href="{{ url_for('style_detail', slug=style.slug) }}" class="btn btn-secondary">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
{{ library_toolbar(
|
||||||
<h2>Style Library</h2>
|
title="Style",
|
||||||
<div class="d-flex gap-1 align-items-center">
|
category="styles",
|
||||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_url=url_for('create_style'),
|
||||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
create_label="Style",
|
||||||
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents">
|
has_batch_gen=true,
|
||||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new style entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
has_regen_all=true,
|
||||||
</form>
|
has_lora_create=true,
|
||||||
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents">
|
bulk_create_url=url_for('bulk_create_styles_from_loras'),
|
||||||
<input type="hidden" name="overwrite" value="true">
|
has_tags=true,
|
||||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all style metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL style LoRAs, consuming significant API credits and overwriting ALL existing style metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
regen_tags_category="styles",
|
||||||
</form>
|
rescan_url=url_for('rescan_styles'),
|
||||||
<a href="{{ url_for('create_style') }}" class="btn btn-sm btn-success">Create New Style</a>
|
get_missing_url="/get_missing_styles",
|
||||||
<form action="{{ url_for('rescan_styles') }}" method="post" class="d-contents">
|
clear_covers_url="/clear_all_style_covers",
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan style files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
generate_url_pattern="/style/{slug}/generate"
|
||||||
</form>
|
) }}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||||
|
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||||
|
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||||
{% for style in styles %}
|
{% for style in styles %}
|
||||||
@@ -40,7 +52,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-center">{{ style.name }}</h5>
|
<h5 class="card-title text-center">{% if style.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ style.name }}{% if style.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||||
<p class="card-text small text-center text-muted">
|
<p class="card-text small text-center text-muted">
|
||||||
{% set ns = namespace(parts=[]) %}
|
{% set ns = namespace(parts=[]) %}
|
||||||
{% if style.data.style is mapping %}
|
{% if style.data.style is mapping %}
|
||||||
@@ -80,111 +92,11 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Handle highlight parameter
|
|
||||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||||
if (highlightSlug) {
|
if (highlightSlug) {
|
||||||
const card = document.getElementById(`card-${highlightSlug}`);
|
const card = document.getElementById(`card-${highlightSlug}`);
|
||||||
if (card) {
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const batchBtn = document.getElementById('batch-generate-btn');
|
|
||||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
|
||||||
const styleNameText = document.getElementById('current-style-name');
|
|
||||||
const stepProgressText = document.getElementById('current-step-progress');
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
|
||||||
else nodeStatus.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBatch() {
|
|
||||||
const response = await fetch('/get_missing_styles');
|
|
||||||
const data = await response.json();
|
|
||||||
const missing = data.missing;
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
alert("No styles missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.disabled = true;
|
|
||||||
regenAllBtn.disabled = true;
|
|
||||||
|
|
||||||
// Phase 1: Queue all jobs upfront
|
|
||||||
|
|
||||||
const jobs = [];
|
|
||||||
for (const style of missing) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
const genResp = await fetch(`/style/${style.slug}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const genData = await genResp.json();
|
|
||||||
if (genData.job_id) jobs.push({ item: style, jobId: genData.job_id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to queue ${style.name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Poll all concurrently
|
|
||||||
let currentItem = '';
|
|
||||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
|
||||||
currentItem = item.name;
|
|
||||||
styleNameText.textContent = `Processing: ${currentItem}`;
|
|
||||||
try {
|
|
||||||
const jobResult = await waitForJob(jobId);
|
|
||||||
if (jobResult.result && jobResult.result.image_url) {
|
|
||||||
const img = document.getElementById(`img-${item.slug}`);
|
|
||||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
|
||||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
|
||||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed for ${item.name}:`, err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
batchBtn.disabled = false;
|
|
||||||
regenAllBtn.disabled = false;
|
|
||||||
alert(`Batch generation complete! ${jobs.length} style images processed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBtn.addEventListener('click', async () => {
|
|
||||||
const response = await fetch('/get_missing_styles');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.missing.length === 0) {
|
|
||||||
alert("No styles missing cover images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Generate cover images for ${data.missing.length} styles?`)) return;
|
|
||||||
runBatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
regenAllBtn.addEventListener('click', async () => {
|
|
||||||
if (!confirm("This will unassign ALL current style cover images and generate new ones. Proceed?")) return;
|
|
||||||
|
|
||||||
const clearResp = await fetch('/clear_all_style_covers', { method: 'POST' });
|
|
||||||
if (clearResp.ok) {
|
|
||||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
|
||||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
|
||||||
runBatch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user