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:
94
CLAUDE.md
94
CLAUDE.md
@@ -46,6 +46,8 @@ routes/
|
||||
transfer.py # Resource transfer system
|
||||
queue_api.py # /api/queue/* endpoints
|
||||
api.py # REST API v1 (preset generation, auth)
|
||||
regenerate.py # Tag regeneration (single + bulk, via LLM queue)
|
||||
search.py # Global search across resources and gallery images
|
||||
```
|
||||
|
||||
### Dependency Graph
|
||||
@@ -90,6 +92,8 @@ All category models (except Settings and Checkpoint) share this pattern:
|
||||
- `data` — full JSON blob (SQLAlchemy JSON column)
|
||||
- `default_fields` — list of `section::key` strings saved as the user's preferred prompt fields
|
||||
- `image_path` — relative path under `static/uploads/`
|
||||
- `is_favourite` — boolean (DB-only, not in JSON; toggled from detail pages)
|
||||
- `is_nsfw` — boolean (mirrored in both DB column and JSON `tags.nsfw`; synced on rescan)
|
||||
|
||||
### Data Flow: JSON → DB → Prompt → ComfyUI
|
||||
|
||||
@@ -156,10 +160,16 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
|
||||
|
||||
### `services/job_queue.py` — Background Job Queue
|
||||
|
||||
Two independent queues with separate worker threads:
|
||||
|
||||
- **ComfyUI queue** (`_job_queue` + `_queue_worker`): Image generation jobs.
|
||||
- **`_enqueue_job(label, workflow, finalize_fn)`** — Adds a generation job to the queue.
|
||||
- **`_make_finalize(category, slug, db_model_class=None, action=None)`** — Factory returning a callback that retrieves the generated image from ComfyUI, saves it, and optionally updates the DB cover image.
|
||||
- **LLM queue** (`_llm_queue` + `_llm_queue_worker`): LLM task jobs (tag regeneration, bulk create with overwrite).
|
||||
- **`_enqueue_task(label, task_fn)`** — Adds an LLM task job. `task_fn` receives the job dict and runs inside `app.app_context()`.
|
||||
- **Shared**: Both queues share `_job_history` (for status lookup by job ID) and `_job_queue_lock`.
|
||||
- **`_prune_job_history(max_age_seconds=3600)`** — Removes old terminal-state jobs from memory.
|
||||
- **`init_queue_worker(flask_app)`** — Stores the app reference and starts the worker thread.
|
||||
- **`init_queue_worker(flask_app)`** — Stores the app reference and starts both worker threads.
|
||||
|
||||
### `services/comfyui.py` — ComfyUI HTTP Client
|
||||
|
||||
@@ -170,13 +180,14 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
|
||||
|
||||
### `services/llm.py` — LLM Integration
|
||||
|
||||
- **`call_llm(prompt, system_prompt)`** — OpenAI-compatible chat completion supporting OpenRouter (cloud) and Ollama/LMStudio (local). Implements a tool-calling loop (up to 10 turns) using `DANBOORU_TOOLS` via MCP Docker container.
|
||||
- **`call_llm(prompt, system_prompt)`** — OpenAI-compatible chat completion supporting OpenRouter (cloud) and Ollama/LMStudio (local). Implements a tool-calling loop (up to 10 turns) using `DANBOORU_TOOLS` via MCP Docker container. Safe to call from background threads (uses `has_request_context()` fallback for OpenRouter HTTP-Referer header).
|
||||
- **`load_prompt(filename)`** — Loads system prompt text from `data/prompts/`.
|
||||
- **`call_mcp_tool()`** — Synchronous wrapper for MCP tool calls.
|
||||
|
||||
### `services/sync.py` — Data Synchronization
|
||||
|
||||
- **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category.
|
||||
- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called in every sync function on both create and update paths.
|
||||
- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers.
|
||||
|
||||
### `services/file_io.py` — File & DB Helpers
|
||||
@@ -203,7 +214,9 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
- `routes/checkpoints.py`: `_build_checkpoint_workflow()` — checkpoint-specific workflow builder
|
||||
- `routes/strengths.py`: `_build_strengths_prompts()`, `_prepare_strengths_workflow()` — strengths gallery helpers
|
||||
- `routes/transfer.py`: `_create_minimal_template()` — transfer template builder
|
||||
- `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()`
|
||||
- `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()`, `_write_sidecar()` — gallery image sidecar JSON I/O
|
||||
- `routes/regenerate.py`: Tag regeneration routes (single + category bulk + all), tag migration
|
||||
- `routes/search.py`: `_search_resources()`, `_search_images()` — global search across resources and gallery
|
||||
|
||||
---
|
||||
|
||||
@@ -221,7 +234,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
},
|
||||
"styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" },
|
||||
"lora": { "lora_name": "Illustrious/Looks/tifa.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
|
||||
"tags": [],
|
||||
"tags": { "origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false },
|
||||
"participants": { "orientation": "1F", "solo_focus": "true" }
|
||||
}
|
||||
```
|
||||
@@ -234,7 +247,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"outfit_name": "French Maid",
|
||||
"wardrobe": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "accessories": "" },
|
||||
"lora": { "lora_name": "Illustrious/Clothing/maid.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
|
||||
"tags": []
|
||||
"tags": { "outfit_type": "Uniform", "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -245,7 +258,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"action_name": "Sitting",
|
||||
"action": { "full_body": "", "additional": "", "head": "", "eyes": "", "arms": "", "hands": "" },
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||
"tags": []
|
||||
"tags": { "participants": "1girl", "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -256,7 +269,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"scene_name": "Beach",
|
||||
"scene": { "background": "", "foreground": "", "furniture": "", "colors": "", "lighting": "", "theme": "" },
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||
"tags": []
|
||||
"tags": { "scene_type": "Outdoor", "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -266,7 +279,8 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"style_id": "watercolor",
|
||||
"style_name": "Watercolor",
|
||||
"style": { "artist_name": "", "artistic_style": "" },
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||
"tags": { "style_type": "Watercolor", "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -277,7 +291,8 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"detailer_name": "Detailed Skin",
|
||||
"prompt": ["detailed skin", "pores"],
|
||||
"focus": { "face": true, "hands": true },
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||
"tags": { "associated_resource": "skin", "adetailer_targets": ["face", "hands"], "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -290,7 +305,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"positive": "casual clothes, jeans",
|
||||
"negative": "revealing",
|
||||
"lora": { "lora_name": "Illustrious/Looks/tifa_casual.safetensors", "lora_weight": 0.85, "lora_triggers": "" },
|
||||
"tags": []
|
||||
"tags": { "origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false }
|
||||
}
|
||||
```
|
||||
Looks occupy LoRA node 16, overriding the character's own LoRA. The Look's `negative` is prepended to the workflow's negative prompt.
|
||||
@@ -329,13 +344,14 @@ Checkpoint JSONs are keyed by `checkpoint_path`. If no JSON exists for a discove
|
||||
|
||||
### Category Pattern (Outfits, Actions, Styles, Scenes, Detailers)
|
||||
Each category follows the same URL pattern:
|
||||
- `GET /<category>/` — gallery
|
||||
- `GET /<category>/` — library with favourite/NSFW filter controls
|
||||
- `GET /<category>/<slug>` — detail + generation UI
|
||||
- `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}`
|
||||
- `POST /<category>/<slug>/replace_cover_from_preview`
|
||||
- `GET/POST /<category>/<slug>/edit`
|
||||
- `POST /<category>/<slug>/upload`
|
||||
- `POST /<category>/<slug>/save_defaults`
|
||||
- `POST /<category>/<slug>/favourite` — toggle `is_favourite` (AJAX)
|
||||
- `POST /<category>/<slug>/clone` — duplicate entry
|
||||
- `POST /<category>/<slug>/save_json` — save raw JSON (from modal editor)
|
||||
- `POST /<category>/rescan`
|
||||
@@ -375,6 +391,19 @@ All generation routes use the background job queue. Frontend polls:
|
||||
|
||||
Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes.
|
||||
|
||||
### Search
|
||||
- `GET /search` — global search page; query params: `q` (search term), `category` (all/characters/outfits/etc.), `nsfw` (all/sfw/nsfw), `type` (all/resources/images)
|
||||
|
||||
### Tag Regeneration
|
||||
- `POST /api/<category>/<slug>/regenerate_tags` — single entity tag regeneration via LLM queue
|
||||
- `POST /admin/bulk_regenerate_tags/<category>` — queue LLM tag regeneration for all entities in a category
|
||||
- `POST /admin/bulk_regenerate_tags` — queue LLM tag regeneration for all resources across all categories
|
||||
- `POST /admin/migrate_tags` — convert old list-format tags to new dict format
|
||||
|
||||
### Gallery Image Metadata
|
||||
- `POST /gallery/image/favourite` — toggle favourite on a gallery image (writes sidecar JSON)
|
||||
- `POST /gallery/image/nsfw` — toggle NSFW on a gallery image (writes sidecar JSON)
|
||||
|
||||
### Utilities
|
||||
- `POST /set_default_checkpoint` — save default checkpoint to session and persist to `comfy_workflow.json`
|
||||
- `GET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}` — AJAX: list items without cover images (sorted by display name)
|
||||
@@ -415,8 +444,10 @@ Text files in `data/prompts/` define JSON output schemas for LLM-generated entri
|
||||
- `character_system.txt` — character JSON schema
|
||||
- `outfit_system.txt` — outfit JSON schema
|
||||
- `action_system.txt`, `scene_system.txt`, `style_system.txt`, `detailer_system.txt`, `look_system.txt`, `checkpoint_system.txt`
|
||||
- `preset_system.txt` — preset JSON schema
|
||||
- `regenerate_tags_system.txt` — tag regeneration schema (all per-category tag structures)
|
||||
|
||||
Used by: character/outfit/action/scene/style create forms, and bulk_create routes.
|
||||
Used by: character/outfit/action/scene/style create forms, bulk_create routes, and tag regeneration. All system prompts include NSFW awareness preamble.
|
||||
|
||||
### Danbooru MCP Tools
|
||||
The LLM loop in `call_llm()` provides three tools via a Docker-based MCP server (`danbooru-mcp:latest`):
|
||||
@@ -430,6 +461,41 @@ All system prompts (`character_system.txt`, `outfit_system.txt`, `action_system.
|
||||
|
||||
---
|
||||
|
||||
## Tagging System
|
||||
|
||||
Tags are **semantic metadata** for organizing and filtering resources. They are **not injected into generation prompts** — tags are purely for the UI (search, filtering, categorization).
|
||||
|
||||
### Tag Schema Per Category
|
||||
|
||||
| Category | Tag fields | Example |
|
||||
|----------|-----------|---------|
|
||||
| Character | `origin_series`, `origin_type`, `nsfw` | `{"origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false}` |
|
||||
| Look | `origin_series`, `origin_type`, `nsfw` | same as Character |
|
||||
| Outfit | `outfit_type`, `nsfw` | `{"outfit_type": "Uniform", "nsfw": false}` |
|
||||
| Action | `participants`, `nsfw` | `{"participants": "1girl, 1boy", "nsfw": true}` |
|
||||
| Style | `style_type`, `nsfw` | `{"style_type": "Anime", "nsfw": false}` |
|
||||
| Scene | `scene_type`, `nsfw` | `{"scene_type": "Indoor", "nsfw": false}` |
|
||||
| Detailer | `associated_resource`, `adetailer_targets`, `nsfw` | `{"associated_resource": "skin", "adetailer_targets": ["face", "hands"], "nsfw": false}` |
|
||||
| Checkpoint | `art_style`, `base_model`, `nsfw` | `{"art_style": "anime", "base_model": "Illustrious", "nsfw": false}` |
|
||||
|
||||
### Favourite / NSFW Columns
|
||||
|
||||
- `is_favourite` — DB-only boolean. Toggled via `POST /<category>/<slug>/favourite`. Not stored in JSON (user preference, not asset metadata).
|
||||
- `is_nsfw` — DB column **and** `tags.nsfw` in JSON. Synced from JSON on rescan via `_sync_nsfw_from_tags()`. Editable from edit pages.
|
||||
|
||||
### Library Filtering
|
||||
|
||||
All library index pages support query params:
|
||||
- `?favourite=on` — show only favourites
|
||||
- `?nsfw=sfw|nsfw|all` — filter by NSFW status
|
||||
- Results are ordered by `is_favourite DESC, name ASC` (favourites sort first).
|
||||
|
||||
### Gallery Image Sidecar Files
|
||||
|
||||
Gallery images can have per-image favourite/NSFW metadata stored in sidecar JSON files at `{image_path}.json` (e.g. `static/uploads/characters/tifa/gen_123.png.json`). Sidecar schema: `{"is_favourite": bool, "is_nsfw": bool}`.
|
||||
|
||||
---
|
||||
|
||||
## LoRA File Paths
|
||||
|
||||
LoRA filenames in JSON are stored as paths relative to ComfyUI's `models/lora/` root:
|
||||
@@ -526,5 +592,7 @@ Volumes mounted into the app container:
|
||||
- **LoRA chaining**: If a LoRA node has no LoRA (name is empty/None), the node is skipped and `model_source`/`clip_source` pass through unchanged. Do not set the node inputs for skipped nodes.
|
||||
- **AJAX detection**: `request.headers.get('X-Requested-With') == 'XMLHttpRequest'` determines whether to return JSON or redirect.
|
||||
- **Session must be marked modified for JSON responses**: After setting session values in AJAX-responding routes, set `session.modified = True`.
|
||||
- **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. When merging into `tags` for `build_prompt`, use `extend` for lists and `append` for strings — never append the list object itself or `", ".join()` will fail on the nested list item.
|
||||
- **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. In generate routes, the detailer prompt is injected directly into `prompts['main']` after `build_prompt()` returns (not via tags or `build_prompt` itself).
|
||||
- **`_make_finalize` action semantics**: Pass `action=None` when the route should always update the DB cover (e.g. batch generate, checkpoint generate). Pass `action=request.form.get('action')` for routes that support both "preview" (no DB update) and "replace" (update DB). The factory skips the DB write when `action` is truthy and not `"replace"`.
|
||||
- **LLM queue runs without request context**: `_enqueue_task()` callbacks execute in a background thread with only `app.app_context()`. Do not access `flask.request`, `flask.session`, or other request-scoped objects inside `task_fn`. Use `has_request_context()` guard if code is shared between HTTP handlers and background tasks.
|
||||
- **Tags are metadata only**: Tags (`data['tags']`) are never injected into generation prompts. They are purely for UI filtering and search. The old pattern of `parts.extend(data.get('tags', []))` in prompt building has been removed.
|
||||
|
||||
14
app.py
14
app.py
@@ -119,6 +119,20 @@ if __name__ == '__main__':
|
||||
else:
|
||||
print(f"Migration settings note ({col_name}): {e}")
|
||||
|
||||
# Migration: Add is_favourite and is_nsfw columns to all resource tables
|
||||
_tag_tables = ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint']
|
||||
for _tbl in _tag_tables:
|
||||
for _col, _type in [('is_favourite', 'BOOLEAN DEFAULT 0'), ('is_nsfw', 'BOOLEAN DEFAULT 0')]:
|
||||
try:
|
||||
db.session.execute(text(f'ALTER TABLE {_tbl} ADD COLUMN {_col} {_type}'))
|
||||
db.session.commit()
|
||||
print(f"Added {_col} column to {_tbl} table")
|
||||
except Exception as e:
|
||||
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
||||
pass
|
||||
else:
|
||||
print(f"Migration note ({_tbl}.{_col}): {e}")
|
||||
|
||||
# Ensure settings exist
|
||||
if not Settings.query.first():
|
||||
db.session.add(Settings())
|
||||
|
||||
@@ -21,6 +21,7 @@ Structure:
|
||||
"feet": "string (foot position)",
|
||||
"additional": "string (extra details)"
|
||||
},
|
||||
"suppress_wardrobe": false,
|
||||
"lora": {
|
||||
"lora_name": "WILL_BE_REPLACED",
|
||||
"lora_weight": 1.0,
|
||||
@@ -28,8 +29,16 @@ Structure:
|
||||
"lora_weight_max": 1.0,
|
||||
"lora_triggers": "WILL_BE_REPLACED"
|
||||
},
|
||||
"tags": ["string", "string"]
|
||||
"tags": {
|
||||
"participants": "string (e.g. 'solo', '1girl 1boy', '2girls', '3girls 1boy')",
|
||||
"nsfw": false
|
||||
}
|
||||
}
|
||||
|
||||
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for sexual, explicit, or fetish actions.
|
||||
|
||||
- `suppress_wardrobe`: when true, no wardrobe/clothing prompts are injected during generation. Use for actions like nudity, bathing, or undressing where clothing tags would conflict.
|
||||
|
||||
Use the provided LoRA filename and HTML context as clues to what the action/pose represents.
|
||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||
|
||||
@@ -50,6 +50,13 @@ Structure:
|
||||
"lora_weight_max": 1.0,
|
||||
"lora_triggers": ""
|
||||
},
|
||||
"tags": ["string", "string"]
|
||||
"tags": {
|
||||
"origin_series": "string (the franchise/series the character is from, e.g. 'Fire Emblem', 'Spy x Family', 'Mario'. Use 'Original' if the character is not from any series)",
|
||||
"origin_type": "string (one of: Anime, Video Game, Cartoon, Movie, Comic, Original)",
|
||||
"nsfw": false
|
||||
}
|
||||
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the tags.
|
||||
}
|
||||
|
||||
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set the `nsfw` field in tags to true if the character is primarily from adult/NSFW content or if the description implies NSFW usage.
|
||||
|
||||
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the prompt field tags (identity, wardrobe, etc). The `tags` object contains semantic metadata — not Danbooru tags.
|
||||
|
||||
@@ -16,8 +16,15 @@ Structure:
|
||||
"steps": 25,
|
||||
"cfg": 5.0,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"vae": "integrated"
|
||||
"vae": "integrated",
|
||||
"tags": {
|
||||
"art_style": "string (one of: Anime, Realistic, Cartoon, Semi-Realistic)",
|
||||
"base_model": "string (one of: Illustrious, Noob — determined from the checkpoint path)",
|
||||
"nsfw": false
|
||||
}
|
||||
}
|
||||
|
||||
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the checkpoint is specifically designed for NSFW content. Determine `base_model` from the checkpoint path (e.g. 'Illustrious/model.safetensors' → 'Illustrious').
|
||||
|
||||
Field guidance:
|
||||
- "base_positive": Comma-separated tags that improve output quality for this specific model. Look for recommended positive prompt tags in the HTML.
|
||||
|
||||
@@ -18,8 +18,16 @@ Structure:
|
||||
"lora_weight_min": 0.7,
|
||||
"lora_weight_max": 1.0,
|
||||
"lora_triggers": "WILL_BE_REPLACED"
|
||||
},
|
||||
"tags": {
|
||||
"associated_resource": "string (one of: General, Looks, Styles, Faces, NSFW — use 'General' for quality/detail enhancers that apply broadly)",
|
||||
"adetailer_targets": ["string (which ADetailer regions this affects: face, hands, body, nsfw)"],
|
||||
"nsfw": false
|
||||
}
|
||||
}
|
||||
|
||||
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for sexually explicit detail enhancers. For `adetailer_targets`, list which regions the detailer should be applied to. Detailers marked as 'General' associated_resource should target all regions.
|
||||
|
||||
Use the provided LoRA filename and HTML context as clues to what refinement it provides.
|
||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||
|
||||
@@ -23,8 +23,15 @@ Structure:
|
||||
"lora_weight_max": 1.0,
|
||||
"lora_triggers": "WILL_BE_REPLACED"
|
||||
},
|
||||
"tags": ["string", "string"]
|
||||
"tags": {
|
||||
"origin_series": "string (the franchise/series the character look is from, e.g. 'Fire Emblem', 'Dragon Ball'. Use 'Original' if not from any series)",
|
||||
"origin_type": "string (one of: Anime, Video Game, Cartoon, Movie, Comic, Original)",
|
||||
"nsfw": false
|
||||
}
|
||||
}
|
||||
|
||||
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the look is primarily NSFW.
|
||||
|
||||
Use the provided LoRA filename and HTML context as clues to what the character look represents.
|
||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the 'positive'/'negative' fields.
|
||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||
|
||||
@@ -30,6 +30,12 @@ Structure:
|
||||
"lora_weight_max": 1.0,
|
||||
"lora_triggers": ""
|
||||
},
|
||||
"tags": ["string", "string"]
|
||||
"tags": {
|
||||
"outfit_type": "string (one of: Formal, Casual, Swimsuit, Lingerie, Underwear, Nude, Cosplay, Uniform, Fantasy, Armor, Traditional)",
|
||||
"nsfw": false
|
||||
}
|
||||
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the tags.
|
||||
}
|
||||
|
||||
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true for lingerie, underwear, nude, or sexually suggestive outfits.
|
||||
|
||||
Fill the fields based on the user's description. Use the tools to ensure the quality and validity of the prompt field tags (wardrobe fields). The `tags` object contains semantic metadata — not Danbooru tags.
|
||||
|
||||
@@ -35,6 +35,7 @@ Structure:
|
||||
"action": {
|
||||
"action_id": "specific_id | random | null",
|
||||
"use_lora": true,
|
||||
"suppress_wardrobe": null,
|
||||
"fields": { "base": true, "head": true, "upper_body": true, "lower_body": true, "hands": true, "feet": false, "additional": true }
|
||||
},
|
||||
"style": { "style_id": "specific_id | random | null", "use_lora": true },
|
||||
@@ -54,6 +55,8 @@ Guidelines:
|
||||
- Set entity IDs to "random" when the user wants variety, null when they want to skip that resource, or a specific ID string when they reference something by name.
|
||||
- Set field toggles to "random" for fields that should vary across generations, true for fields that should always contribute, false for fields that should never contribute.
|
||||
- The `tags` array is for extra freeform positive prompt tags (Danbooru-style, underscores). Validate them with the tools.
|
||||
- `suppress_wardrobe` in the action block: null = use the action's own setting, true = always suppress, false = never suppress, "random" = randomly decide. When suppressed, no wardrobe/clothing prompts are injected.
|
||||
- The `resolution` object sets image dimensions. Set `random` to true to pick a random aspect ratio each generation. When `random` is false, `width` and `height` are used directly. Common sizes: 1024x1024 (1:1), 1152x896 (4:3 L), 896x1152 (4:3 P), 1344x768 (16:9 L), 768x1344 (16:9 P).
|
||||
- Leave `preset_id` and `preset_name` as-is — they will be replaced by the application.
|
||||
- This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Consider NSFW compatibility when selecting entity combinations.
|
||||
- Output ONLY valid JSON. No explanations, no markdown fences.
|
||||
|
||||
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_triggers": "WILL_BE_REPLACED"
|
||||
},
|
||||
"tags": ["string", "string"]
|
||||
"tags": {
|
||||
"scene_type": "string (one of: Indoor, Outdoor, Fantasy, Urban, Nature, Abstract)",
|
||||
"nsfw": false
|
||||
}
|
||||
}
|
||||
|
||||
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the scene is inherently NSFW (e.g. love hotel, dungeon).
|
||||
|
||||
Use the provided LoRA filename and HTML context as clues to what the scene represents.
|
||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight', 'lora_triggers', and the descriptive fields.
|
||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||
|
||||
@@ -21,8 +21,15 @@ Structure:
|
||||
"lora_weight_min": 0.7,
|
||||
"lora_weight_max": 1.0,
|
||||
"lora_triggers": "WILL_BE_REPLACED"
|
||||
},
|
||||
"tags": {
|
||||
"style_type": "string (one of: Anime, Realistic, Western, Artistic, Sketch, Watercolor, Digital, Pixel Art)",
|
||||
"nsfw": false
|
||||
}
|
||||
}
|
||||
|
||||
This tool generates content for both SFW and NSFW image generation. NSFW content is permitted and expected. Set `nsfw` to true if the style is primarily used for NSFW content.
|
||||
|
||||
Use the provided LoRA filename and HTML context as clues to what artist or style it represents.
|
||||
IMPORTANT: Look for suggested LoRA strength/weight (e.g. 'Strength of 0.7', 'recommended weight: 0.8', 'use at 0.6-0.8'), trigger words (e.g. 'Trigger: xyz'), and recommended/optional prompt tags in the HTML text. Use these found values to populate 'lora_weight' and 'lora_triggers'.
|
||||
- If the HTML suggests a specific weight (e.g. 0.7), set 'lora_weight' to that value and set 'lora_weight_min' to max(0.0, weight - 0.1) and 'lora_weight_max' to min(2.0, weight + 0.1).
|
||||
|
||||
17
models.py
17
models.py
@@ -13,6 +13,9 @@ class Character(db.Model):
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
active_outfit = db.Column(db.String(100), default='default')
|
||||
|
||||
is_favourite = db.Column(db.Boolean, default=False)
|
||||
is_nsfw = db.Column(db.Boolean, default=False)
|
||||
|
||||
# NEW: Outfit assignment support (Phase 4)
|
||||
assigned_outfit_ids = db.Column(db.JSON, default=list) # List of outfit_ids from Outfit table
|
||||
default_outfit_id = db.Column(db.String(100), default='default') # 'default' or specific outfit_id
|
||||
@@ -161,6 +164,8 @@ class Look(db.Model):
|
||||
data = db.Column(db.JSON, nullable=False)
|
||||
default_fields = db.Column(db.JSON, nullable=True)
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
is_favourite = db.Column(db.Boolean, default=False)
|
||||
is_nsfw = db.Column(db.Boolean, default=False)
|
||||
|
||||
def get_linked_characters(self):
|
||||
"""Get all characters linked to this look."""
|
||||
@@ -192,6 +197,8 @@ class Outfit(db.Model):
|
||||
data = db.Column(db.JSON, nullable=False)
|
||||
default_fields = db.Column(db.JSON, nullable=True)
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
is_favourite = db.Column(db.Boolean, default=False)
|
||||
is_nsfw = db.Column(db.Boolean, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Outfit {self.outfit_id}>'
|
||||
@@ -205,6 +212,8 @@ class Action(db.Model):
|
||||
data = db.Column(db.JSON, nullable=False)
|
||||
default_fields = db.Column(db.JSON, nullable=True)
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
is_favourite = db.Column(db.Boolean, default=False)
|
||||
is_nsfw = db.Column(db.Boolean, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Action {self.action_id}>'
|
||||
@@ -218,6 +227,8 @@ class Style(db.Model):
|
||||
data = db.Column(db.JSON, nullable=False)
|
||||
default_fields = db.Column(db.JSON, nullable=True)
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
is_favourite = db.Column(db.Boolean, default=False)
|
||||
is_nsfw = db.Column(db.Boolean, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Style {self.style_id}>'
|
||||
@@ -231,6 +242,8 @@ class Scene(db.Model):
|
||||
data = db.Column(db.JSON, nullable=False)
|
||||
default_fields = db.Column(db.JSON, nullable=True)
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
is_favourite = db.Column(db.Boolean, default=False)
|
||||
is_nsfw = db.Column(db.Boolean, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Scene {self.scene_id}>'
|
||||
@@ -244,6 +257,8 @@ class Detailer(db.Model):
|
||||
data = db.Column(db.JSON, nullable=False)
|
||||
default_fields = db.Column(db.JSON, nullable=True)
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
is_favourite = db.Column(db.Boolean, default=False)
|
||||
is_nsfw = db.Column(db.Boolean, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Detailer {self.detailer_id}>'
|
||||
@@ -256,6 +271,8 @@ class Checkpoint(db.Model):
|
||||
checkpoint_path = db.Column(db.String(255), nullable=False) # e.g. "Illustrious/model.safetensors"
|
||||
data = db.Column(db.JSON, nullable=True)
|
||||
image_path = db.Column(db.String(255), nullable=True)
|
||||
is_favourite = db.Column(db.Boolean, default=False)
|
||||
is_nsfw = db.Column(db.Boolean, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Checkpoint {self.checkpoint_id}>'
|
||||
|
||||
@@ -18,6 +18,8 @@ def register_routes(app):
|
||||
from routes import strengths
|
||||
from routes import transfer
|
||||
from routes import api
|
||||
from routes import regenerate
|
||||
from routes import search
|
||||
|
||||
queue_api.register_routes(app)
|
||||
settings.register_routes(app)
|
||||
@@ -37,3 +39,5 @@ def register_routes(app):
|
||||
strengths.register_routes(app)
|
||||
transfer.register_routes(app)
|
||||
api.register_routes(app)
|
||||
regenerate.register_routes(app)
|
||||
search.register_routes(app)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
|
||||
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Action, Outfit, Style, Scene, Detailer, Checkpoint, Settings, Look
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||
from services.sync import sync_actions
|
||||
from services.file_io import get_available_loras
|
||||
@@ -38,8 +37,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/actions')
|
||||
def actions_index():
|
||||
actions = Action.query.order_by(Action.name).all()
|
||||
return render_template('actions/index.html', actions=actions)
|
||||
query = Action.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
actions = query.order_by(Action.is_favourite.desc(), Action.name).all()
|
||||
return render_template('actions/index.html', actions=actions, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/actions/rescan', methods=['POST'])
|
||||
def rescan_actions():
|
||||
@@ -118,9 +126,15 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
||||
# Suppress wardrobe toggle
|
||||
new_data['suppress_wardrobe'] = request.form.get('suppress_wardrobe') == 'on'
|
||||
|
||||
# Update Tags (structured dict)
|
||||
new_data['tags'] = {
|
||||
'participants': request.form.get('tag_participants', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
action.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
action.data = new_data
|
||||
flag_modified(action, "data")
|
||||
@@ -201,6 +215,12 @@ def register_routes(app):
|
||||
session[f'extra_neg_action_{slug}'] = extra_negative
|
||||
session.modified = True
|
||||
|
||||
suppress_wardrobe = action_obj.data.get('suppress_wardrobe', False)
|
||||
|
||||
# Strip any wardrobe fields from manual selection when suppressed
|
||||
if suppress_wardrobe:
|
||||
selected_fields = [f for f in selected_fields if not f.startswith('wardrobe::')]
|
||||
|
||||
# Build combined data for prompt building
|
||||
if character:
|
||||
# Combine character identity/wardrobe with action details
|
||||
@@ -232,16 +252,13 @@ def register_routes(app):
|
||||
if 'lora' not in combined_data: combined_data['lora'] = {}
|
||||
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}"
|
||||
|
||||
# Merge tags
|
||||
combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', [])))
|
||||
|
||||
# Use action's defaults if no manual selection
|
||||
if not selected_fields:
|
||||
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
|
||||
|
||||
# Auto-include essential character fields if a character is selected
|
||||
if selected_fields:
|
||||
_ensure_character_fields(character, selected_fields)
|
||||
_ensure_character_fields(character, selected_fields, include_wardrobe=not suppress_wardrobe)
|
||||
else:
|
||||
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
|
||||
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
|
||||
@@ -249,7 +266,8 @@ def register_routes(app):
|
||||
for key in ['base', 'head']:
|
||||
if character.data.get('identity', {}).get(key):
|
||||
selected_fields.append(f'identity::{key}')
|
||||
# Add wardrobe fields
|
||||
# Add wardrobe fields (unless suppressed)
|
||||
if not suppress_wardrobe:
|
||||
from utils import _WARDROBE_KEYS
|
||||
wardrobe = character.get_active_wardrobe()
|
||||
for key in _WARDROBE_KEYS:
|
||||
@@ -281,7 +299,7 @@ def register_routes(app):
|
||||
'tags': action_obj.data.get('tags', [])
|
||||
}
|
||||
if not selected_fields:
|
||||
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags']
|
||||
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers']
|
||||
default_fields = action_obj.default_fields
|
||||
active_outfit = 'default'
|
||||
|
||||
@@ -322,7 +340,8 @@ def register_routes(app):
|
||||
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
|
||||
extra_parts.append(val)
|
||||
|
||||
# Wardrobe (active outfit)
|
||||
# Wardrobe (active outfit) — skip if suppressed
|
||||
if not suppress_wardrobe:
|
||||
from utils import _WARDROBE_KEYS
|
||||
wardrobe = extra_char.get_active_wardrobe()
|
||||
for key in _WARDROBE_KEYS:
|
||||
@@ -391,21 +410,27 @@ def register_routes(app):
|
||||
actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(actions_lora_dir)
|
||||
if not os.path.exists(actions_lora_dir):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Actions LoRA directory not found.'}, 400
|
||||
flash('Actions LoRA directory not found.', 'error')
|
||||
return redirect(url_for('actions_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
|
||||
system_prompt = load_prompt('action_system.txt')
|
||||
if not system_prompt:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Action system prompt file not found.'}, 500
|
||||
flash('Action system prompt file not found.', 'error')
|
||||
return redirect(url_for('actions_index'))
|
||||
|
||||
for filename in os.listdir(actions_lora_dir):
|
||||
if filename.endswith('.safetensors'):
|
||||
job_ids = []
|
||||
skipped = 0
|
||||
|
||||
for filename in sorted(os.listdir(actions_lora_dir)):
|
||||
if not filename.endswith('.safetensors'):
|
||||
continue
|
||||
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
||||
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
@@ -415,48 +440,46 @@ def register_routes(app):
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
html_path = os.path.join(actions_lora_dir, html_filename)
|
||||
# Read HTML companion file if it exists
|
||||
html_path = os.path.join(actions_lora_dir, f"{name_base}.html")
|
||||
html_content = ""
|
||||
if os.path.exists(html_path):
|
||||
try:
|
||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||
html_raw = hf.read()
|
||||
# Strip HTML tags but keep text content for LLM context
|
||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML {html_filename}: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
print(f"Asking LLM to describe action: {action_name}")
|
||||
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
def make_task(fn, aid, aname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||
def task_fn(job):
|
||||
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA filename: '{fn}'"
|
||||
if html_ctx:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
|
||||
# Clean response
|
||||
llm_response = call_llm(prompt, sys_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
action_data = json.loads(clean_json)
|
||||
|
||||
# Enforce system values while preserving LLM-extracted metadata
|
||||
action_data['action_id'] = action_id
|
||||
action_data['action_name'] = action_name
|
||||
action_data['action_id'] = aid
|
||||
action_data['action_name'] = aname
|
||||
|
||||
# Update lora dict safely
|
||||
if 'lora' not in action_data: action_data['lora'] = {}
|
||||
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if 'lora' not in action_data:
|
||||
action_data['lora'] = {}
|
||||
action_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||
|
||||
# Fallbacks if LLM failed to extract metadata
|
||||
if not action_data['lora'].get('lora_triggers'):
|
||||
action_data['lora']['lora_triggers'] = name_base
|
||||
action_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||
if action_data['lora'].get('lora_weight') is None:
|
||||
action_data['lora']['lora_weight'] = 1.0
|
||||
if action_data['lora'].get('lora_weight_min') is None:
|
||||
@@ -464,39 +487,45 @@ def register_routes(app):
|
||||
if action_data['lora'].get('lora_weight_max') is None:
|
||||
action_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
with open(json_path, 'w') as f:
|
||||
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||
with open(jp, 'w') as f:
|
||||
json.dump(action_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': aname, 'action': 'overwritten' if is_exist else 'created'}
|
||||
return task_fn
|
||||
|
||||
# Small delay to avoid API rate limits if many files
|
||||
time.sleep(0.5)
|
||||
job = _enqueue_task(
|
||||
f"Create action: {action_name}",
|
||||
make_task(filename, action_id, action_name, json_path,
|
||||
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||
)
|
||||
job_ids.append(job['id'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating action for {filename}: {e}")
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
# Enqueue a sync task to run after all creates
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_actions()
|
||||
msg = f'Successfully processed actions: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No actions created or overwritten. {skipped_count} existing actions found.')
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync actions DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
|
||||
flash(f'Queued {len(job_ids)} action creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||
return redirect(url_for('actions_index'))
|
||||
|
||||
@app.route('/action/create', methods=['GET', 'POST'])
|
||||
def create_action():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
prompt = request.form.get('prompt', '')
|
||||
use_llm = request.form.get('use_llm') == 'on'
|
||||
|
||||
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
@@ -513,12 +542,12 @@ def register_routes(app):
|
||||
if use_llm:
|
||||
if not prompt:
|
||||
flash("Description is required when AI generation is enabled.")
|
||||
return redirect(request.url)
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
|
||||
system_prompt = load_prompt('action_system.txt')
|
||||
if not system_prompt:
|
||||
flash("Action system prompt file not found.")
|
||||
return redirect(request.url)
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
|
||||
try:
|
||||
llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt)
|
||||
@@ -529,7 +558,7 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"LLM error: {e}")
|
||||
flash(f"Failed to generate action profile: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
else:
|
||||
action_data = {
|
||||
"action_id": safe_slug,
|
||||
@@ -538,6 +567,7 @@ def register_routes(app):
|
||||
"base": "", "head": "", "upper_body": "", "lower_body": "",
|
||||
"hands": "", "feet": "", "additional": ""
|
||||
},
|
||||
"suppress_wardrobe": False,
|
||||
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
|
||||
"tags": []
|
||||
}
|
||||
@@ -559,9 +589,9 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create action: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
|
||||
return render_template('actions/create.html')
|
||||
return render_template('actions/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/action/<path:slug>/clone', methods=['POST'])
|
||||
def clone_action(slug):
|
||||
@@ -619,3 +649,12 @@ def register_routes(app):
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/action/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_action_favourite(slug):
|
||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
||||
action.is_favourite = not action.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': action.is_favourite}
|
||||
return redirect(url_for('action_detail', slug=slug))
|
||||
|
||||
@@ -10,7 +10,7 @@ from werkzeug.utils import secure_filename
|
||||
from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
|
||||
from services.file_io import get_available_loras
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.llm import call_llm, load_prompt
|
||||
from services.llm import call_character_mcp_tool, call_llm, load_prompt
|
||||
from services.prompts import build_prompt
|
||||
from services.sync import sync_characters
|
||||
from services.workflow import _get_default_checkpoint, _prepare_workflow
|
||||
@@ -23,8 +23,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
characters = Character.query.order_by(Character.name).all()
|
||||
return render_template('index.html', characters=characters)
|
||||
query = Character.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
characters = query.order_by(Character.is_favourite.desc(), Character.name).all()
|
||||
return render_template('index.html', characters=characters, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/rescan', methods=['POST'])
|
||||
def rescan():
|
||||
@@ -219,6 +228,7 @@ def register_routes(app):
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
prompt = request.form.get('prompt', '')
|
||||
wiki_url = request.form.get('wiki_url', '').strip()
|
||||
use_llm = request.form.get('use_llm') == 'on'
|
||||
outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none'
|
||||
existing_outfit_id = request.form.get('existing_outfit_id')
|
||||
@@ -228,6 +238,7 @@ def register_routes(app):
|
||||
'name': name,
|
||||
'filename': slug,
|
||||
'prompt': prompt,
|
||||
'wiki_url': wiki_url,
|
||||
'use_llm': use_llm,
|
||||
'outfit_mode': outfit_mode,
|
||||
'existing_outfit_id': existing_outfit_id
|
||||
@@ -261,6 +272,20 @@ def register_routes(app):
|
||||
flash(error_msg)
|
||||
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
|
||||
|
||||
# Fetch reference data from wiki URL if provided
|
||||
wiki_reference = ''
|
||||
if wiki_url:
|
||||
logger.info(f"Fetching character data from URL: {wiki_url}")
|
||||
wiki_data = call_character_mcp_tool('get_character_from_url', {
|
||||
'url': wiki_url,
|
||||
'name': name,
|
||||
})
|
||||
if wiki_data:
|
||||
wiki_reference = f"\n\nReference data from wiki:\n{wiki_data}\n\nUse this reference to accurately describe the character's appearance, outfit, and features."
|
||||
logger.info(f"Got wiki reference data ({len(wiki_data)} chars)")
|
||||
else:
|
||||
logger.warning(f"Failed to fetch wiki data from {wiki_url}")
|
||||
|
||||
# Step 1: Generate or select outfit first
|
||||
default_outfit_id = 'default'
|
||||
generated_outfit = None
|
||||
@@ -271,7 +296,7 @@ def register_routes(app):
|
||||
outfit_name = f"{name} - default"
|
||||
|
||||
outfit_prompt = f"""Generate an outfit for character "{name}".
|
||||
The character is described as: {prompt}
|
||||
The character is described as: {prompt}{wiki_reference}
|
||||
|
||||
Create an outfit JSON with wardrobe fields appropriate for this character."""
|
||||
|
||||
@@ -344,7 +369,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
|
||||
|
||||
# Step 2: Generate character (without wardrobe section)
|
||||
char_prompt = f"""Generate a character named "{name}".
|
||||
Description: {prompt}
|
||||
Description: {prompt}{wiki_reference}
|
||||
|
||||
Default Outfit: {default_outfit_id}
|
||||
|
||||
@@ -516,9 +541,13 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
||||
if form_key in request.form:
|
||||
new_data['wardrobe'][key] = request.form.get(form_key)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
||||
# Update structured tags
|
||||
new_data['tags'] = {
|
||||
'origin_series': request.form.get('tag_origin_series', '').strip(),
|
||||
'origin_type': request.form.get('tag_origin_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
character.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
character.data = new_data
|
||||
flag_modified(character, "data")
|
||||
@@ -867,3 +896,12 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
||||
db.session.commit()
|
||||
flash('Default prompt selection saved for this character!')
|
||||
return redirect(url_for('detail', slug=slug))
|
||||
|
||||
@app.route('/character/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_character_favourite(slug):
|
||||
character = Character.query.filter_by(slug=slug).first_or_404()
|
||||
character.is_favourite = not character.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': character.is_favourite}
|
||||
return redirect(url_for('detail', slug=slug))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Checkpoint, Character, Settings
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||
from services.sync import sync_checkpoints, _default_checkpoint_data
|
||||
from services.file_io import get_available_checkpoints
|
||||
@@ -57,8 +56,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/checkpoints')
|
||||
def checkpoints_index():
|
||||
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
|
||||
return render_template('checkpoints/index.html', checkpoints=checkpoints)
|
||||
query = Checkpoint.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
checkpoints = query.order_by(Checkpoint.is_favourite.desc(), Checkpoint.name).all()
|
||||
return render_template('checkpoints/index.html', checkpoints=checkpoints, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/checkpoints/rescan', methods=['POST'])
|
||||
def rescan_checkpoints():
|
||||
@@ -189,9 +197,9 @@ def register_routes(app):
|
||||
os.makedirs(checkpoints_dir, exist_ok=True)
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
skipped = 0
|
||||
written_directly = 0
|
||||
job_ids = []
|
||||
|
||||
system_prompt = load_prompt('checkpoint_system.txt')
|
||||
if not system_prompt:
|
||||
@@ -219,7 +227,7 @@ def register_routes(app):
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Look for a matching HTML file alongside the model file
|
||||
@@ -235,52 +243,72 @@ def register_routes(app):
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML for {filename}: {e}")
|
||||
logger.error("Error reading HTML for %s: %s", filename, e)
|
||||
|
||||
defaults = _default_checkpoint_data(checkpoint_path, filename)
|
||||
|
||||
if html_content:
|
||||
try:
|
||||
print(f"Asking LLM to describe checkpoint: {filename}")
|
||||
# Has HTML companion — enqueue LLM task
|
||||
def make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing):
|
||||
def task_fn(job):
|
||||
prompt = (
|
||||
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
|
||||
f"(checkpoint_path: '{checkpoint_path}').\n\n"
|
||||
f"Here is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
||||
)
|
||||
try:
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
ckpt_data = json.loads(clean_json)
|
||||
# Enforce fixed fields
|
||||
ckpt_data['checkpoint_path'] = checkpoint_path
|
||||
ckpt_data['checkpoint_name'] = filename
|
||||
# Fill missing fields with defaults
|
||||
for key, val in defaults.items():
|
||||
if key not in ckpt_data or ckpt_data[key] is None:
|
||||
ckpt_data[key] = val
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"LLM error for {filename}: {e}. Using defaults.")
|
||||
ckpt_data = defaults
|
||||
else:
|
||||
logger.error("LLM error for %s: %s. Using defaults.", filename, e)
|
||||
ckpt_data = defaults
|
||||
|
||||
try:
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(ckpt_data, f, indent=2)
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
|
||||
job['result'] = {'name': filename, 'action': 'overwritten' if is_existing else 'created'}
|
||||
return task_fn
|
||||
|
||||
job = _enqueue_task(f"Create checkpoint: {filename}", make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing))
|
||||
job_ids.append(job['id'])
|
||||
else:
|
||||
created_count += 1
|
||||
# No HTML — write defaults directly (no LLM needed)
|
||||
try:
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(defaults, f, indent=2)
|
||||
written_directly += 1
|
||||
except Exception as e:
|
||||
print(f"Error saving JSON for {filename}: {e}")
|
||||
logger.error("Error saving JSON for %s: %s", filename, e)
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
needs_sync = len(job_ids) > 0 or written_directly > 0
|
||||
|
||||
if needs_sync:
|
||||
if job_ids:
|
||||
# Sync after all LLM tasks complete
|
||||
def sync_task(job):
|
||||
sync_checkpoints()
|
||||
msg = f'Successfully processed checkpoints: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync checkpoints DB", sync_task)
|
||||
else:
|
||||
flash(f'No checkpoints created or overwritten. {skipped_count} existing entries found.')
|
||||
# No LLM tasks — sync immediately
|
||||
sync_checkpoints()
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'written_directly': written_directly, 'skipped': skipped}
|
||||
flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).')
|
||||
return redirect(url_for('checkpoints_index'))
|
||||
|
||||
@app.route('/checkpoint/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_checkpoint_favourite(slug):
|
||||
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
|
||||
ckpt.is_favourite = not ckpt.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': ckpt.is_favourite}
|
||||
return redirect(url_for('checkpoint_detail', slug=slug))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||
from services.sync import sync_detailers
|
||||
from services.file_io import get_available_loras
|
||||
@@ -27,11 +26,8 @@ def register_routes(app):
|
||||
combined_data = character.data.copy()
|
||||
combined_data['character_id'] = character.character_id
|
||||
|
||||
# Merge detailer prompt into character's tags
|
||||
# Capture detailer prompt for injection into main prompt later
|
||||
detailer_prompt = detailer_obj.data.get('prompt', '')
|
||||
if detailer_prompt:
|
||||
if 'tags' not in combined_data: combined_data['tags'] = []
|
||||
combined_data['tags'].append(detailer_prompt)
|
||||
|
||||
# Merge detailer lora triggers if present
|
||||
detailer_lora = detailer_obj.data.get('lora', {})
|
||||
@@ -53,21 +49,19 @@ def register_routes(app):
|
||||
for key in _WARDROBE_KEYS:
|
||||
if wardrobe.get(key):
|
||||
selected_fields.append(f'wardrobe::{key}')
|
||||
selected_fields.extend(['special::tags', 'lora::lora_triggers'])
|
||||
selected_fields.extend(['lora::lora_triggers'])
|
||||
|
||||
default_fields = detailer_obj.default_fields
|
||||
active_outfit = character.active_outfit
|
||||
else:
|
||||
# Detailer only - no character
|
||||
detailer_prompt = detailer_obj.data.get('prompt', '')
|
||||
detailer_tags = [detailer_prompt] if detailer_prompt else []
|
||||
combined_data = {
|
||||
'character_id': detailer_obj.detailer_id,
|
||||
'tags': detailer_tags,
|
||||
'lora': detailer_obj.data.get('lora', {}),
|
||||
}
|
||||
if not selected_fields:
|
||||
selected_fields = ['special::tags', 'lora::lora_triggers']
|
||||
selected_fields = ['lora::lora_triggers']
|
||||
default_fields = detailer_obj.default_fields
|
||||
active_outfit = 'default'
|
||||
|
||||
@@ -76,6 +70,11 @@ def register_routes(app):
|
||||
|
||||
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
|
||||
|
||||
# Inject detailer prompt directly into main prompt
|
||||
if detailer_prompt:
|
||||
prompt_str = detailer_prompt if isinstance(detailer_prompt, str) else ', '.join(detailer_prompt)
|
||||
prompts['main'] = f"{prompts['main']}, {prompt_str}" if prompts['main'] else prompt_str
|
||||
|
||||
_append_background(prompts, character)
|
||||
|
||||
if extra_positive:
|
||||
@@ -87,8 +86,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/detailers')
|
||||
def detailers_index():
|
||||
detailers = Detailer.query.order_by(Detailer.name).all()
|
||||
return render_template('detailers/index.html', detailers=detailers)
|
||||
query = Detailer.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
detailers = query.order_by(Detailer.is_favourite.desc(), Detailer.name).all()
|
||||
return render_template('detailers/index.html', detailers=detailers, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/detailers/rescan', methods=['POST'])
|
||||
def rescan_detailers():
|
||||
@@ -162,9 +170,13 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
||||
# Update structured tags
|
||||
new_data['tags'] = {
|
||||
'associated_resource': request.form.get('tag_associated_resource', '').strip(),
|
||||
'adetailer_targets': request.form.getlist('tag_adetailer_targets'),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
detailer.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
detailer.data = new_data
|
||||
flag_modified(detailer, "data")
|
||||
@@ -318,15 +330,16 @@ def register_routes(app):
|
||||
return redirect(url_for('detailers_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
skipped = 0
|
||||
job_ids = []
|
||||
|
||||
system_prompt = load_prompt('detailer_system.txt')
|
||||
if not system_prompt:
|
||||
flash('Detailer system prompt file not found.', 'error')
|
||||
return redirect(url_for('detailers_index'))
|
||||
|
||||
detailers_dir = app.config['DETAILERS_DIR']
|
||||
|
||||
for filename in os.listdir(detailers_lora_dir):
|
||||
if filename.endswith('.safetensors'):
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
@@ -334,11 +347,11 @@ def register_routes(app):
|
||||
detailer_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
|
||||
json_filename = f"{detailer_id}.json"
|
||||
json_path = os.path.join(app.config['DETAILERS_DIR'], json_filename)
|
||||
json_path = os.path.join(detailers_dir, json_filename)
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
@@ -354,10 +367,10 @@ def register_routes(app):
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML {html_filename}: {e}")
|
||||
logger.error("Error reading HTML %s: %s", html_filename, e)
|
||||
|
||||
try:
|
||||
print(f"Asking LLM to describe detailer: {detailer_name}")
|
||||
def make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||
def task_fn(job):
|
||||
prompt = f"Describe a detailer LoRA for AI image generation based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
@@ -384,33 +397,33 @@ def register_routes(app):
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(detailer_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': detailer_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||
return task_fn
|
||||
|
||||
# Small delay to avoid API rate limits if many files
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"Error creating detailer for {filename}: {e}")
|
||||
job = _enqueue_task(f"Create detailer: {detailer_name}", make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||
job_ids.append(job['id'])
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_detailers()
|
||||
msg = f'Successfully processed detailers: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No new detailers created or overwritten. {skipped_count} existing detailers found.')
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync detailers DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
flash(f'Queued {len(job_ids)} detailer tasks ({skipped} skipped).')
|
||||
return redirect(url_for('detailers_index'))
|
||||
|
||||
@app.route('/detailer/create', methods=['GET', 'POST'])
|
||||
def create_detailer():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
|
||||
form_data = {'name': name, 'filename': slug}
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
@@ -452,6 +465,15 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create detailer: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('detailers/create.html', form_data=form_data)
|
||||
|
||||
return render_template('detailers/create.html')
|
||||
return render_template('detailers/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/detailer/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_detailer_favourite(slug):
|
||||
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
|
||||
detailer.is_favourite = not detailer.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': detailer.is_favourite}
|
||||
return redirect(url_for('detailer_detail', slug=slug))
|
||||
|
||||
@@ -4,13 +4,13 @@ import logging
|
||||
|
||||
from flask import render_template, request, current_app
|
||||
from models import (
|
||||
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint,
|
||||
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint, Preset,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
|
||||
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints']
|
||||
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints', 'looks', 'presets', 'generator']
|
||||
|
||||
_MODEL_MAP = {
|
||||
'characters': Character,
|
||||
@@ -20,11 +20,36 @@ _MODEL_MAP = {
|
||||
'styles': Style,
|
||||
'detailers': Detailer,
|
||||
'checkpoints': Checkpoint,
|
||||
'looks': Look,
|
||||
'presets': Preset,
|
||||
'generator': Preset,
|
||||
}
|
||||
|
||||
# Maps xref_category param names to sidecar JSON keys
|
||||
_XREF_KEY_MAP = {
|
||||
'character': 'character_slug',
|
||||
'outfit': 'outfit_slug',
|
||||
'action': 'action_slug',
|
||||
'style': 'style_slug',
|
||||
'scene': 'scene_slug',
|
||||
'detailer': 'detailer_slug',
|
||||
'look': 'look_slug',
|
||||
'preset': 'preset_slug',
|
||||
}
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
def _read_sidecar(upload_folder, image_path):
|
||||
"""Read JSON sidecar for an image. Returns dict or None."""
|
||||
sidecar = image_path.rsplit('.', 1)[0] + '.json'
|
||||
sidecar_path = os.path.join(upload_folder, sidecar)
|
||||
try:
|
||||
with open(sidecar_path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def _scan_gallery_images(category_filter='all', slug_filter=''):
|
||||
"""Return sorted list of image dicts from the uploads directory."""
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
@@ -164,18 +189,48 @@ def register_routes(app):
|
||||
category = request.args.get('category', 'all')
|
||||
slug = request.args.get('slug', '')
|
||||
sort = request.args.get('sort', 'newest')
|
||||
xref_category = request.args.get('xref_category', '')
|
||||
xref_slug = request.args.get('xref_slug', '')
|
||||
favourite_filter = request.args.get('favourite', '')
|
||||
nsfw_filter = request.args.get('nsfw', 'all')
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
per_page = int(request.args.get('per_page', 48))
|
||||
per_page = per_page if per_page in (24, 48, 96) else 48
|
||||
|
||||
images = _scan_gallery_images(category, slug)
|
||||
|
||||
# Read sidecar data for filtering (favourite/NSFW/xref)
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
need_sidecar = (xref_category and xref_slug) or favourite_filter or nsfw_filter != 'all'
|
||||
if need_sidecar:
|
||||
for img in images:
|
||||
img['_sidecar'] = _read_sidecar(upload_folder, img['path']) or {}
|
||||
|
||||
# Cross-reference filter
|
||||
if xref_category and xref_slug and xref_category in _XREF_KEY_MAP:
|
||||
sidecar_key = _XREF_KEY_MAP[xref_category]
|
||||
images = [img for img in images if img.get('_sidecar', {}).get(sidecar_key) == xref_slug]
|
||||
|
||||
# Favourite filter
|
||||
if favourite_filter == 'on':
|
||||
images = [img for img in images if img.get('_sidecar', {}).get('is_favourite')]
|
||||
|
||||
# NSFW filter
|
||||
if nsfw_filter == 'sfw':
|
||||
images = [img for img in images if not img.get('_sidecar', {}).get('is_nsfw')]
|
||||
elif nsfw_filter == 'nsfw':
|
||||
images = [img for img in images if img.get('_sidecar', {}).get('is_nsfw')]
|
||||
|
||||
if sort == 'oldest':
|
||||
images.reverse()
|
||||
elif sort == 'random':
|
||||
import random
|
||||
random.shuffle(images)
|
||||
|
||||
# Sort favourites first when favourite filter not active but sort is newest/oldest
|
||||
if sort in ('newest', 'oldest') and not favourite_filter and need_sidecar:
|
||||
images.sort(key=lambda x: (not x.get('_sidecar', {}).get('is_favourite', False), images.index(x)))
|
||||
|
||||
total = len(images)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = min(page, total_pages)
|
||||
@@ -197,6 +252,11 @@ def register_routes(app):
|
||||
if Model:
|
||||
slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()]
|
||||
|
||||
# Attach sidecar data to page images for template use
|
||||
for img in page_images:
|
||||
if '_sidecar' not in img:
|
||||
img['_sidecar'] = _read_sidecar(os.path.abspath(app.config['UPLOAD_FOLDER']), img['path']) or {}
|
||||
|
||||
return render_template(
|
||||
'gallery.html',
|
||||
images=page_images,
|
||||
@@ -209,6 +269,10 @@ def register_routes(app):
|
||||
sort=sort,
|
||||
categories=GALLERY_CATEGORIES,
|
||||
slug_options=slug_options,
|
||||
xref_category=xref_category,
|
||||
xref_slug=xref_slug,
|
||||
favourite_filter=favourite_filter,
|
||||
nsfw_filter=nsfw_filter,
|
||||
)
|
||||
|
||||
@app.route('/gallery/prompt-data')
|
||||
@@ -228,8 +292,60 @@ def register_routes(app):
|
||||
|
||||
meta = _parse_comfy_png_metadata(abs_img)
|
||||
meta['path'] = img_path
|
||||
|
||||
# Include sidecar data if available (for cross-reference links)
|
||||
sidecar = _read_sidecar(upload_folder, img_path)
|
||||
if sidecar:
|
||||
meta['sidecar'] = sidecar
|
||||
|
||||
return meta
|
||||
|
||||
def _write_sidecar(upload_folder, image_path, data):
|
||||
"""Write/update JSON sidecar for an image."""
|
||||
sidecar = image_path.rsplit('.', 1)[0] + '.json'
|
||||
sidecar_path = os.path.join(upload_folder, sidecar)
|
||||
existing = {}
|
||||
try:
|
||||
with open(sidecar_path) as f:
|
||||
existing = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
existing.update(data)
|
||||
with open(sidecar_path, 'w') as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
|
||||
@app.route('/gallery/image/favourite', methods=['POST'])
|
||||
def gallery_image_favourite():
|
||||
"""Toggle favourite on a gallery image via sidecar JSON."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
img_path = data.get('path', '')
|
||||
if not img_path:
|
||||
return {'error': 'path required'}, 400
|
||||
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
|
||||
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
|
||||
if not abs_img.startswith(upload_folder + os.sep) or not os.path.isfile(abs_img):
|
||||
return {'error': 'Invalid path'}, 400
|
||||
sidecar = _read_sidecar(upload_folder, img_path) or {}
|
||||
new_val = not sidecar.get('is_favourite', False)
|
||||
_write_sidecar(upload_folder, img_path, {'is_favourite': new_val})
|
||||
return {'success': True, 'is_favourite': new_val}
|
||||
|
||||
@app.route('/gallery/image/nsfw', methods=['POST'])
|
||||
def gallery_image_nsfw():
|
||||
"""Toggle NSFW on a gallery image via sidecar JSON."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
img_path = data.get('path', '')
|
||||
if not img_path:
|
||||
return {'error': 'path required'}, 400
|
||||
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
|
||||
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
|
||||
if not abs_img.startswith(upload_folder + os.sep) or not os.path.isfile(abs_img):
|
||||
return {'error': 'Invalid path'}, 400
|
||||
sidecar = _read_sidecar(upload_folder, img_path) or {}
|
||||
new_val = not sidecar.get('is_nsfw', False)
|
||||
_write_sidecar(upload_folder, img_path, {'is_nsfw': new_val})
|
||||
return {'success': True, 'is_nsfw': new_val}
|
||||
|
||||
@app.route('/gallery/delete', methods=['POST'])
|
||||
def gallery_delete():
|
||||
"""Delete a generated image from the gallery. Only the image file is removed."""
|
||||
@@ -249,6 +365,10 @@ def register_routes(app):
|
||||
|
||||
if os.path.isfile(abs_img):
|
||||
os.remove(abs_img)
|
||||
# Also remove sidecar JSON if present
|
||||
sidecar = abs_img.rsplit('.', 1)[0] + '.json'
|
||||
if os.path.isfile(sidecar):
|
||||
os.remove(sidecar)
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
@@ -260,6 +380,7 @@ def register_routes(app):
|
||||
hard: removes JSON data file + LoRA/checkpoint safetensors + DB record.
|
||||
"""
|
||||
_RESOURCE_MODEL_MAP = {
|
||||
'characters': Character,
|
||||
'looks': Look,
|
||||
'styles': Style,
|
||||
'actions': Action,
|
||||
@@ -269,6 +390,7 @@ def register_routes(app):
|
||||
'checkpoints': Checkpoint,
|
||||
}
|
||||
_RESOURCE_DATA_DIRS = {
|
||||
'characters': app.config['CHARACTERS_DIR'],
|
||||
'looks': app.config['LOOKS_DIR'],
|
||||
'styles': app.config['STYLES_DIR'],
|
||||
'actions': app.config['ACTIONS_DIR'],
|
||||
|
||||
@@ -1,154 +1,135 @@
|
||||
import json
|
||||
import logging
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint
|
||||
from services.prompts import build_prompt, build_extras_prompt
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from models import Preset
|
||||
from services.generation import generate_from_preset
|
||||
from services.file_io import get_available_checkpoints
|
||||
from services.comfyui import get_loaded_checkpoint
|
||||
from services.workflow import _get_default_checkpoint
|
||||
from services.sync import _resolve_preset_entity
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
@app.route('/generator', methods=['GET', 'POST'])
|
||||
@app.route('/generator', methods=['GET'])
|
||||
def generator():
|
||||
characters = Character.query.order_by(Character.name).all()
|
||||
presets = Preset.query.order_by(Preset.name).all()
|
||||
checkpoints = get_available_checkpoints()
|
||||
actions = Action.query.order_by(Action.name).all()
|
||||
outfits = Outfit.query.order_by(Outfit.name).all()
|
||||
scenes = Scene.query.order_by(Scene.name).all()
|
||||
styles = Style.query.order_by(Style.name).all()
|
||||
detailers = Detailer.query.order_by(Detailer.name).all()
|
||||
|
||||
if not checkpoints:
|
||||
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
|
||||
|
||||
# Default to whatever is currently loaded in ComfyUI, then settings default
|
||||
selected_ckpt = get_loaded_checkpoint()
|
||||
if not selected_ckpt:
|
||||
default_path, _ = _get_default_checkpoint()
|
||||
selected_ckpt = default_path
|
||||
|
||||
if request.method == 'POST':
|
||||
char_slug = request.form.get('character')
|
||||
checkpoint = request.form.get('checkpoint')
|
||||
custom_positive = request.form.get('positive_prompt', '')
|
||||
custom_negative = request.form.get('negative_prompt', '')
|
||||
# Pre-select preset from query param
|
||||
preset_slug = request.args.get('preset', '')
|
||||
|
||||
action_slugs = request.form.getlist('action_slugs')
|
||||
outfit_slugs = request.form.getlist('outfit_slugs')
|
||||
scene_slugs = request.form.getlist('scene_slugs')
|
||||
style_slugs = request.form.getlist('style_slugs')
|
||||
detailer_slugs = request.form.getlist('detailer_slugs')
|
||||
override_prompt = request.form.get('override_prompt', '').strip()
|
||||
width = request.form.get('width') or 1024
|
||||
height = request.form.get('height') or 1024
|
||||
return render_template('generator.html',
|
||||
presets=presets,
|
||||
checkpoints=checkpoints,
|
||||
selected_ckpt=selected_ckpt,
|
||||
preset_slug=preset_slug)
|
||||
|
||||
character = Character.query.filter_by(slug=char_slug).first_or_404()
|
||||
@app.route('/generator/generate', methods=['POST'])
|
||||
def generator_generate():
|
||||
preset_slug = request.form.get('preset_slug', '').strip()
|
||||
if not preset_slug:
|
||||
return {'error': 'No preset selected'}, 400
|
||||
|
||||
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
|
||||
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
|
||||
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
|
||||
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
|
||||
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
|
||||
preset = Preset.query.filter_by(slug=preset_slug).first()
|
||||
if not preset:
|
||||
return {'error': 'Preset not found'}, 404
|
||||
|
||||
try:
|
||||
with open('comfy_workflow.json', 'r') as f:
|
||||
workflow = json.load(f)
|
||||
overrides = {
|
||||
'checkpoint': request.form.get('checkpoint', '').strip() or None,
|
||||
'extra_positive': request.form.get('extra_positive', '').strip(),
|
||||
'extra_negative': request.form.get('extra_negative', '').strip(),
|
||||
'action': 'preview',
|
||||
}
|
||||
|
||||
# Build base prompts from character defaults
|
||||
prompts = build_prompt(character.data, default_fields=character.default_fields)
|
||||
|
||||
if override_prompt:
|
||||
prompts["main"] = override_prompt
|
||||
else:
|
||||
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
|
||||
combined = prompts["main"]
|
||||
if extras:
|
||||
combined = f"{combined}, {extras}"
|
||||
if custom_positive:
|
||||
combined = f"{custom_positive}, {combined}"
|
||||
prompts["main"] = combined
|
||||
|
||||
# Apply face/hand prompt overrides if provided
|
||||
override_face = request.form.get('override_face_prompt', '').strip()
|
||||
override_hand = request.form.get('override_hand_prompt', '').strip()
|
||||
if override_face:
|
||||
prompts["face"] = override_face
|
||||
if override_hand:
|
||||
prompts["hand"] = override_hand
|
||||
|
||||
# Parse optional seed
|
||||
seed_val = request.form.get('seed', '').strip()
|
||||
fixed_seed = int(seed_val) if seed_val else None
|
||||
if seed_val:
|
||||
overrides['seed'] = int(seed_val)
|
||||
|
||||
# Prepare workflow - first selected item per category supplies its LoRA slot
|
||||
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint).first() if checkpoint else None
|
||||
workflow = _prepare_workflow(
|
||||
workflow, character, prompts, checkpoint, custom_negative,
|
||||
outfit=sel_outfits[0] if sel_outfits else None,
|
||||
action=sel_actions[0] if sel_actions else None,
|
||||
style=sel_styles[0] if sel_styles else None,
|
||||
detailer=sel_detailers[0] if sel_detailers else None,
|
||||
scene=sel_scenes[0] if sel_scenes else None,
|
||||
width=width,
|
||||
height=height,
|
||||
checkpoint_data=ckpt_obj.data if ckpt_obj else None,
|
||||
fixed_seed=fixed_seed,
|
||||
)
|
||||
width = request.form.get('width', '').strip()
|
||||
height = request.form.get('height', '').strip()
|
||||
if width and height:
|
||||
overrides['width'] = int(width)
|
||||
overrides['height'] = int(height)
|
||||
|
||||
print(f"Queueing generator prompt for {character.character_id}")
|
||||
|
||||
_finalize = _make_finalize('characters', character.slug)
|
||||
label = f"Generator: {character.name}"
|
||||
job = _enqueue_job(label, workflow, _finalize)
|
||||
job = generate_from_preset(preset, overrides, save_category='generator')
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'status': 'queued', 'job_id': job['id']}
|
||||
|
||||
flash("Generation queued.")
|
||||
return redirect(url_for('generator', preset=preset_slug))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Generator error: {e}")
|
||||
logger.exception("Generator error: %s", e)
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': str(e)}, 500
|
||||
flash(f"Error: {str(e)}")
|
||||
return redirect(url_for('generator', preset=preset_slug))
|
||||
|
||||
return render_template('generator.html', characters=characters, checkpoints=checkpoints,
|
||||
actions=actions, outfits=outfits, scenes=scenes,
|
||||
styles=styles, detailers=detailers, selected_ckpt=selected_ckpt)
|
||||
@app.route('/generator/preset_info', methods=['GET'])
|
||||
def generator_preset_info():
|
||||
"""Return resolved entity names for a preset (for the summary panel)."""
|
||||
slug = request.args.get('slug', '')
|
||||
if not slug:
|
||||
return {'error': 'slug required'}, 400
|
||||
|
||||
@app.route('/generator/preview_prompt', methods=['POST'])
|
||||
def generator_preview_prompt():
|
||||
char_slug = request.form.get('character')
|
||||
if not char_slug:
|
||||
return {'error': 'No character selected'}, 400
|
||||
preset = Preset.query.filter_by(slug=slug).first()
|
||||
if not preset:
|
||||
return {'error': 'not found'}, 404
|
||||
|
||||
character = Character.query.filter_by(slug=char_slug).first()
|
||||
if not character:
|
||||
return {'error': 'Character not found'}, 404
|
||||
data = preset.data
|
||||
info = {}
|
||||
|
||||
action_slugs = request.form.getlist('action_slugs')
|
||||
outfit_slugs = request.form.getlist('outfit_slugs')
|
||||
scene_slugs = request.form.getlist('scene_slugs')
|
||||
style_slugs = request.form.getlist('style_slugs')
|
||||
detailer_slugs = request.form.getlist('detailer_slugs')
|
||||
custom_positive = request.form.get('positive_prompt', '')
|
||||
# Character
|
||||
char_cfg = data.get('character', {})
|
||||
char_id = char_cfg.get('character_id')
|
||||
if char_id == 'random':
|
||||
info['character'] = 'Random'
|
||||
elif char_id:
|
||||
obj = _resolve_preset_entity('character', char_id)
|
||||
info['character'] = obj.name if obj else char_id
|
||||
else:
|
||||
info['character'] = None
|
||||
|
||||
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
|
||||
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
|
||||
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
|
||||
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
|
||||
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
|
||||
# Secondary entities
|
||||
for key, label in [('outfit', 'outfit'), ('action', 'action'), ('style', 'style'),
|
||||
('scene', 'scene'), ('detailer', 'detailer'), ('look', 'look')]:
|
||||
cfg = data.get(key, {})
|
||||
eid = cfg.get(f'{key}_id')
|
||||
if eid == 'random':
|
||||
info[label] = 'Random'
|
||||
elif eid:
|
||||
obj = _resolve_preset_entity(key, eid)
|
||||
info[label] = obj.name if obj else eid
|
||||
else:
|
||||
info[label] = None
|
||||
|
||||
prompts = build_prompt(character.data, default_fields=character.default_fields)
|
||||
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
|
||||
combined = prompts["main"]
|
||||
if extras:
|
||||
combined = f"{combined}, {extras}"
|
||||
if custom_positive:
|
||||
combined = f"{custom_positive}, {combined}"
|
||||
# Checkpoint
|
||||
ckpt_cfg = data.get('checkpoint', {})
|
||||
ckpt_path = ckpt_cfg.get('checkpoint_path')
|
||||
if ckpt_path == 'random':
|
||||
info['checkpoint'] = 'Random'
|
||||
elif ckpt_path:
|
||||
info['checkpoint'] = ckpt_path.split('/')[-1].replace('.safetensors', '')
|
||||
else:
|
||||
info['checkpoint'] = 'Default'
|
||||
|
||||
return {'prompt': combined, 'face': prompts['face'], 'hand': prompts['hand']}
|
||||
# Resolution
|
||||
res_cfg = data.get('resolution', {})
|
||||
if res_cfg.get('random'):
|
||||
info['resolution'] = 'Random'
|
||||
elif res_cfg.get('width') and res_cfg.get('height'):
|
||||
info['resolution'] = f"{res_cfg['width']}x{res_cfg['height']}"
|
||||
else:
|
||||
info['resolution'] = 'Default'
|
||||
|
||||
return info
|
||||
|
||||
111
routes/looks.py
111
routes/looks.py
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags
|
||||
from services.sync import sync_looks
|
||||
from services.file_io import get_available_loras, _count_look_assignments
|
||||
@@ -58,9 +57,18 @@ def register_routes(app):
|
||||
|
||||
@app.route('/looks')
|
||||
def looks_index():
|
||||
looks = Look.query.order_by(Look.name).all()
|
||||
query = Look.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
looks = query.order_by(Look.is_favourite.desc(), Look.name).all()
|
||||
look_assignments = _count_look_assignments()
|
||||
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments)
|
||||
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/looks/rescan', methods=['POST'])
|
||||
def rescan_looks():
|
||||
@@ -144,8 +152,12 @@ def register_routes(app):
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
||||
new_data['tags'] = {
|
||||
'origin_series': request.form.get('tag_origin_series', '').strip(),
|
||||
'origin_type': request.form.get('tag_origin_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
look.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
look.data = new_data
|
||||
flag_modified(look, 'data')
|
||||
@@ -435,19 +447,32 @@ Character ID: {character_slug}"""
|
||||
def create_look():
|
||||
characters = Character.query.order_by(Character.name).all()
|
||||
loras = get_available_loras('characters')
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
|
||||
filename = f'{look_id}.json'
|
||||
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
|
||||
|
||||
character_id = request.form.get('character_id', '') or None
|
||||
lora_name = request.form.get('lora_lora_name', '')
|
||||
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
|
||||
lora_triggers = request.form.get('lora_lora_triggers', '')
|
||||
positive = request.form.get('positive', '')
|
||||
negative = request.form.get('negative', '')
|
||||
tags = [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()]
|
||||
tags = {
|
||||
'origin_series': request.form.get('tag_origin_series', '').strip(),
|
||||
'origin_type': request.form.get('tag_origin_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
|
||||
form_data = {
|
||||
'name': name, 'character_id': character_id,
|
||||
'lora_lora_name': lora_name, 'lora_lora_weight': lora_weight,
|
||||
'lora_lora_triggers': lora_triggers, 'positive': positive,
|
||||
'negative': negative, 'tags': tags,
|
||||
}
|
||||
|
||||
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
|
||||
filename = f'{look_id}.json'
|
||||
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
|
||||
|
||||
data = {
|
||||
'look_id': look_id,
|
||||
@@ -459,20 +484,26 @@ Character ID: {character_slug}"""
|
||||
'tags': tags
|
||||
}
|
||||
|
||||
try:
|
||||
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
|
||||
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
|
||||
character_id=character_id, data=data)
|
||||
character_id=character_id, data=data,
|
||||
is_nsfw=tags.get('nsfw', False))
|
||||
db.session.add(new_look)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Look "{name}" created!')
|
||||
return redirect(url_for('look_detail', slug=slug))
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create look: {e}")
|
||||
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
||||
|
||||
return render_template('looks/create.html', characters=characters, loras=loras)
|
||||
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
||||
|
||||
@app.route('/get_missing_looks')
|
||||
def get_missing_looks():
|
||||
@@ -497,15 +528,16 @@ Character ID: {character_slug}"""
|
||||
return redirect(url_for('looks_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
skipped = 0
|
||||
job_ids = []
|
||||
|
||||
system_prompt = load_prompt('look_system.txt')
|
||||
if not system_prompt:
|
||||
flash('Look system prompt file not found.', 'error')
|
||||
return redirect(url_for('looks_index'))
|
||||
|
||||
looks_dir = app.config['LOOKS_DIR']
|
||||
|
||||
for filename in os.listdir(lora_dir):
|
||||
if not filename.endswith('.safetensors'):
|
||||
continue
|
||||
@@ -515,11 +547,11 @@ Character ID: {character_slug}"""
|
||||
look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
|
||||
json_filename = f"{look_id}.json"
|
||||
json_path = os.path.join(app.config['LOOKS_DIR'], json_filename)
|
||||
json_path = os.path.join(looks_dir, json_filename)
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
@@ -535,10 +567,10 @@ Character ID: {character_slug}"""
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML {html_filename}: {e}")
|
||||
logger.error("Error reading HTML %s: %s", html_filename, e)
|
||||
|
||||
try:
|
||||
print(f"Asking LLM to describe look: {look_name}")
|
||||
def make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||
def task_fn(job):
|
||||
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
||||
@@ -562,27 +594,32 @@ Character ID: {character_slug}"""
|
||||
if look_data['lora'].get('lora_weight_max') is None:
|
||||
look_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
|
||||
os.makedirs(looks_dir, exist_ok=True)
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(look_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': look_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||
return task_fn
|
||||
|
||||
time.sleep(0.5)
|
||||
job = _enqueue_task(f"Create look: {look_name}", make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||
job_ids.append(job['id'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating look for {filename}: {e}")
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_looks()
|
||||
msg = f'Successfully processed looks: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No looks created or overwritten. {skipped_count} existing entries found.')
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync looks DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
|
||||
return redirect(url_for('looks_index'))
|
||||
|
||||
@app.route('/look/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_look_favourite(slug):
|
||||
look = Look.query.filter_by(slug=slug).first_or_404()
|
||||
look.is_favourite = not look.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': look.is_favourite}
|
||||
return redirect(url_for('look_detail', slug=slug))
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint, Settings, Look
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||
from services.sync import sync_outfits
|
||||
from services.file_io import get_available_loras, _count_outfit_lora_assignments
|
||||
@@ -37,9 +36,18 @@ def register_routes(app):
|
||||
|
||||
@app.route('/outfits')
|
||||
def outfits_index():
|
||||
outfits = Outfit.query.order_by(Outfit.name).all()
|
||||
query = Outfit.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
outfits = query.order_by(Outfit.is_favourite.desc(), Outfit.name).all()
|
||||
lora_assignments = _count_outfit_lora_assignments()
|
||||
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments)
|
||||
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/outfits/rescan', methods=['POST'])
|
||||
def rescan_outfits():
|
||||
@@ -53,20 +61,24 @@ def register_routes(app):
|
||||
clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(clothing_lora_dir)
|
||||
if not os.path.exists(clothing_lora_dir):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Clothing LoRA directory not found.'}, 400
|
||||
flash('Clothing LoRA directory not found.', 'error')
|
||||
return redirect(url_for('outfits_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
|
||||
system_prompt = load_prompt('outfit_system.txt')
|
||||
if not system_prompt:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Outfit system prompt file not found.'}, 500
|
||||
flash('Outfit system prompt file not found.', 'error')
|
||||
return redirect(url_for('outfits_index'))
|
||||
|
||||
for filename in os.listdir(clothing_lora_dir):
|
||||
job_ids = []
|
||||
skipped = 0
|
||||
|
||||
for filename in sorted(os.listdir(clothing_lora_dir)):
|
||||
if not filename.endswith('.safetensors'):
|
||||
continue
|
||||
|
||||
@@ -79,11 +91,11 @@ def register_routes(app):
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
html_path = os.path.join(clothing_lora_dir, html_filename)
|
||||
# Read HTML companion file if it exists
|
||||
html_path = os.path.join(clothing_lora_dir, f"{name_base}.html")
|
||||
html_content = ""
|
||||
if os.path.exists(html_path):
|
||||
try:
|
||||
@@ -94,27 +106,27 @@ def register_routes(app):
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML {html_filename}: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
print(f"Asking LLM to describe outfit: {outfit_name}")
|
||||
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
|
||||
def make_task(fn, oid, oname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||
def task_fn(job):
|
||||
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{fn}'"
|
||||
if html_ctx:
|
||||
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_ctx[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
llm_response = call_llm(prompt, sys_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
outfit_data = json.loads(clean_json)
|
||||
|
||||
outfit_data['outfit_id'] = outfit_id
|
||||
outfit_data['outfit_name'] = outfit_name
|
||||
outfit_data['outfit_id'] = oid
|
||||
outfit_data['outfit_name'] = oname
|
||||
|
||||
if 'lora' not in outfit_data:
|
||||
outfit_data['lora'] = {}
|
||||
outfit_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
outfit_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||
if not outfit_data['lora'].get('lora_triggers'):
|
||||
outfit_data['lora']['lora_triggers'] = name_base
|
||||
outfit_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||
if outfit_data['lora'].get('lora_weight') is None:
|
||||
outfit_data['lora']['lora_weight'] = 0.8
|
||||
if outfit_data['lora'].get('lora_weight_min') is None:
|
||||
@@ -122,29 +134,31 @@ def register_routes(app):
|
||||
if outfit_data['lora'].get('lora_weight_max') is None:
|
||||
outfit_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
os.makedirs(app.config['CLOTHING_DIR'], exist_ok=True)
|
||||
with open(json_path, 'w') as f:
|
||||
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||
with open(jp, 'w') as f:
|
||||
json.dump(outfit_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': oname, 'action': 'overwritten' if is_exist else 'created'}
|
||||
return task_fn
|
||||
|
||||
time.sleep(0.5)
|
||||
job = _enqueue_task(
|
||||
f"Create outfit: {outfit_name}",
|
||||
make_task(filename, outfit_id, outfit_name, json_path,
|
||||
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||
)
|
||||
job_ids.append(job['id'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating outfit for {filename}: {e}")
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
# Enqueue a sync task to run after all creates
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_outfits()
|
||||
msg = f'Successfully processed outfits: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No outfits created or overwritten. {skipped_count} existing entries found.')
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync outfits DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
|
||||
flash(f'Queued {len(job_ids)} outfit creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||
return redirect(url_for('outfits_index'))
|
||||
|
||||
def _get_linked_characters_for_outfit(outfit):
|
||||
@@ -232,9 +246,12 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
|
||||
# Update Tags (structured dict)
|
||||
new_data['tags'] = {
|
||||
'outfit_type': request.form.get('tag_outfit_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
outfit.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
outfit.data = new_data
|
||||
flag_modified(outfit, "data")
|
||||
@@ -409,12 +426,16 @@ def register_routes(app):
|
||||
|
||||
@app.route('/outfit/create', methods=['GET', 'POST'])
|
||||
def create_outfit():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
prompt = request.form.get('prompt', '')
|
||||
use_llm = request.form.get('use_llm') == 'on'
|
||||
|
||||
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
|
||||
|
||||
# Auto-generate slug from name if not provided
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
@@ -435,13 +456,13 @@ def register_routes(app):
|
||||
if use_llm:
|
||||
if not prompt:
|
||||
flash("Description is required when AI generation is enabled.")
|
||||
return redirect(request.url)
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
|
||||
# Generate JSON with LLM
|
||||
system_prompt = load_prompt('outfit_system.txt')
|
||||
if not system_prompt:
|
||||
flash("System prompt file not found.")
|
||||
return redirect(request.url)
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
|
||||
try:
|
||||
llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt)
|
||||
@@ -477,7 +498,7 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"LLM error: {e}")
|
||||
flash(f"Failed to generate outfit profile: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
else:
|
||||
# Create blank outfit template
|
||||
outfit_data = {
|
||||
@@ -523,9 +544,9 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create outfit: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
|
||||
return render_template('outfits/create.html')
|
||||
return render_template('outfits/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/outfit/<path:slug>/save_defaults', methods=['POST'])
|
||||
def save_outfit_defaults(slug):
|
||||
@@ -601,3 +622,12 @@ def register_routes(app):
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/outfit/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_outfit_favourite(slug):
|
||||
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
|
||||
outfit.is_favourite = not outfit.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': outfit.is_favourite}
|
||||
return redirect(url_for('outfit_detail', slug=slug))
|
||||
|
||||
@@ -149,6 +149,8 @@ def register_routes(app):
|
||||
'use_lora': request.form.get('outfit_use_lora') == 'on'},
|
||||
'action': {'action_id': _entity_id(request.form.get('action_id')),
|
||||
'use_lora': request.form.get('action_use_lora') == 'on',
|
||||
'suppress_wardrobe': {'true': True, 'false': False, 'random': 'random'}.get(
|
||||
request.form.get('act_suppress_wardrobe')),
|
||||
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
|
||||
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
|
||||
'style': {'style_id': _entity_id(request.form.get('style_id')),
|
||||
@@ -247,11 +249,15 @@ def register_routes(app):
|
||||
|
||||
@app.route('/preset/create', methods=['GET', 'POST'])
|
||||
def create_preset():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
use_llm = request.form.get('use_llm') == 'on'
|
||||
|
||||
form_data = {'name': name, 'description': description, 'use_llm': use_llm}
|
||||
|
||||
safe_id = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') or 'preset'
|
||||
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
|
||||
base_id = safe_id
|
||||
@@ -265,7 +271,7 @@ def register_routes(app):
|
||||
system_prompt = load_prompt('preset_system.txt')
|
||||
if not system_prompt:
|
||||
flash('Preset system prompt file not found.', 'error')
|
||||
return redirect(request.url)
|
||||
return render_template('presets/create.html', form_data=form_data)
|
||||
try:
|
||||
llm_response = call_llm(
|
||||
f"Create a preset profile named '{name}' based on this description: {description}",
|
||||
@@ -276,7 +282,7 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
logger.exception("LLM error creating preset: %s", e)
|
||||
flash(f"AI generation failed: {e}", 'error')
|
||||
return redirect(request.url)
|
||||
return render_template('presets/create.html', form_data=form_data)
|
||||
else:
|
||||
preset_data = {
|
||||
'character': {'character_id': 'random', 'use_lora': True,
|
||||
@@ -314,7 +320,7 @@ def register_routes(app):
|
||||
flash(f"Preset '{name}' created!")
|
||||
return redirect(url_for('edit_preset', slug=safe_slug))
|
||||
|
||||
return render_template('presets/create.html')
|
||||
return render_template('presets/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/get_missing_presets')
|
||||
def get_missing_presets():
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import logging
|
||||
from services.job_queue import (
|
||||
_job_queue_lock, _job_queue, _job_history, _queue_worker_event,
|
||||
_job_queue_lock, _job_queue, _llm_queue, _job_history,
|
||||
_queue_worker_event, _llm_worker_event,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
# Both queues for iteration
|
||||
_ALL_QUEUES = (_job_queue, _llm_queue)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
@@ -12,23 +16,27 @@ def register_routes(app):
|
||||
def api_queue_list():
|
||||
"""Return the current queue as JSON."""
|
||||
with _job_queue_lock:
|
||||
jobs = [
|
||||
{
|
||||
jobs = []
|
||||
for q in _ALL_QUEUES:
|
||||
for j in q:
|
||||
jobs.append({
|
||||
'id': j['id'],
|
||||
'label': j['label'],
|
||||
'status': j['status'],
|
||||
'error': j['error'],
|
||||
'created_at': j['created_at'],
|
||||
}
|
||||
for j in _job_queue
|
||||
]
|
||||
'job_type': j.get('job_type', 'comfyui'),
|
||||
})
|
||||
return {'jobs': jobs, 'count': len(jobs)}
|
||||
|
||||
@app.route('/api/queue/count')
|
||||
def api_queue_count():
|
||||
"""Return just the count of active (non-done, non-failed) jobs."""
|
||||
with _job_queue_lock:
|
||||
count = sum(1 for j in _job_queue if j['status'] in ('pending', 'processing', 'paused'))
|
||||
count = sum(
|
||||
1 for q in _ALL_QUEUES for j in q
|
||||
if j['status'] in ('pending', 'processing', 'paused')
|
||||
)
|
||||
return {'count': count}
|
||||
|
||||
@app.route('/api/queue/<job_id>/remove', methods=['POST'])
|
||||
@@ -40,10 +48,12 @@ def register_routes(app):
|
||||
return {'error': 'Job not found'}, 404
|
||||
if job['status'] == 'processing':
|
||||
return {'error': 'Cannot remove a job that is currently processing'}, 400
|
||||
for q in _ALL_QUEUES:
|
||||
try:
|
||||
_job_queue.remove(job)
|
||||
q.remove(job)
|
||||
break
|
||||
except ValueError:
|
||||
pass # Already not in queue
|
||||
continue
|
||||
job['status'] = 'removed'
|
||||
return {'status': 'ok'}
|
||||
|
||||
@@ -58,6 +68,10 @@ def register_routes(app):
|
||||
job['status'] = 'paused'
|
||||
elif job['status'] == 'paused':
|
||||
job['status'] = 'pending'
|
||||
# Signal the appropriate worker
|
||||
if job.get('job_type') == 'llm':
|
||||
_llm_worker_event.set()
|
||||
else:
|
||||
_queue_worker_event.set()
|
||||
else:
|
||||
return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400
|
||||
@@ -65,13 +79,14 @@ def register_routes(app):
|
||||
|
||||
@app.route('/api/queue/clear', methods=['POST'])
|
||||
def api_queue_clear():
|
||||
"""Clear all pending jobs from the queue (allows current processing job to finish)."""
|
||||
"""Clear all pending jobs from the queue (allows current processing jobs to finish)."""
|
||||
removed_count = 0
|
||||
with _job_queue_lock:
|
||||
pending_jobs = [j for j in _job_queue if j['status'] == 'pending']
|
||||
for q in _ALL_QUEUES:
|
||||
pending_jobs = [j for j in q if j['status'] == 'pending']
|
||||
for job in pending_jobs:
|
||||
try:
|
||||
_job_queue.remove(job)
|
||||
q.remove(job)
|
||||
job['status'] = 'removed'
|
||||
removed_count += 1
|
||||
except ValueError:
|
||||
@@ -91,7 +106,8 @@ def register_routes(app):
|
||||
'label': job['label'],
|
||||
'status': job['status'],
|
||||
'error': job['error'],
|
||||
'comfy_prompt_id': job['comfy_prompt_id'],
|
||||
'job_type': job.get('job_type', 'comfyui'),
|
||||
'comfy_prompt_id': job.get('comfy_prompt_id'),
|
||||
}
|
||||
if job.get('result'):
|
||||
resp['result'] = job['result']
|
||||
|
||||
202
routes/regenerate.py
Normal file
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 os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||
from services.sync import sync_scenes
|
||||
from services.file_io import get_available_loras
|
||||
@@ -37,8 +36,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/scenes')
|
||||
def scenes_index():
|
||||
scenes = Scene.query.order_by(Scene.name).all()
|
||||
return render_template('scenes/index.html', scenes=scenes)
|
||||
query = Scene.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
scenes = query.order_by(Scene.is_favourite.desc(), Scene.name).all()
|
||||
return render_template('scenes/index.html', scenes=scenes, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/scenes/rescan', methods=['POST'])
|
||||
def rescan_scenes():
|
||||
@@ -117,9 +125,12 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (comma separated string to list)
|
||||
tags_raw = request.form.get('tags', '')
|
||||
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
||||
# Update Tags (structured dict)
|
||||
new_data['tags'] = {
|
||||
'scene_type': request.form.get('tag_scene_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
scene.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
scene.data = new_data
|
||||
flag_modified(scene, "data")
|
||||
@@ -332,15 +343,16 @@ def register_routes(app):
|
||||
return redirect(url_for('scenes_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
skipped = 0
|
||||
job_ids = []
|
||||
|
||||
system_prompt = load_prompt('scene_system.txt')
|
||||
if not system_prompt:
|
||||
flash('Scene system prompt file not found.', 'error')
|
||||
return redirect(url_for('scenes_index'))
|
||||
|
||||
scenes_dir = app.config['SCENES_DIR']
|
||||
|
||||
for filename in os.listdir(backgrounds_lora_dir):
|
||||
if filename.endswith('.safetensors'):
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
@@ -348,11 +360,11 @@ def register_routes(app):
|
||||
scene_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
|
||||
json_filename = f"{scene_id}.json"
|
||||
json_path = os.path.join(app.config['SCENES_DIR'], json_filename)
|
||||
json_path = os.path.join(scenes_dir, json_filename)
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
@@ -362,28 +374,24 @@ def register_routes(app):
|
||||
try:
|
||||
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
|
||||
html_raw = hf.read()
|
||||
# Strip HTML tags but keep text content for LLM context
|
||||
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML {html_filename}: {e}")
|
||||
logger.error("Error reading HTML %s: %s", html_filename, e)
|
||||
|
||||
try:
|
||||
print(f"Asking LLM to describe scene: {scene_name}")
|
||||
def make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
|
||||
def task_fn(job):
|
||||
prompt = f"Describe a scene for an AI image generation model based on the LoRA filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
|
||||
# Clean response
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
scene_data = json.loads(clean_json)
|
||||
|
||||
# Enforce system values while preserving LLM-extracted metadata
|
||||
scene_data['scene_id'] = scene_id
|
||||
scene_data['scene_name'] = scene_name
|
||||
|
||||
@@ -402,34 +410,33 @@ def register_routes(app):
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(scene_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': scene_name, 'action': 'overwritten' if is_existing else 'created'}
|
||||
return task_fn
|
||||
|
||||
# Small delay to avoid API rate limits if many files
|
||||
time.sleep(0.5)
|
||||
job = _enqueue_task(f"Create scene: {scene_name}", make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
|
||||
job_ids.append(job['id'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating scene for {filename}: {e}")
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_scenes()
|
||||
msg = f'Successfully processed scenes: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No scenes created or overwritten. {skipped_count} existing scenes found.')
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync scenes DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
flash(f'Queued {len(job_ids)} scene tasks ({skipped} skipped).')
|
||||
return redirect(url_for('scenes_index'))
|
||||
|
||||
@app.route('/scene/create', methods=['GET', 'POST'])
|
||||
def create_scene():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
|
||||
form_data = {'name': name, 'filename': slug}
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
@@ -478,9 +485,9 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create scene: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('scenes/create.html', form_data=form_data)
|
||||
|
||||
return render_template('scenes/create.html')
|
||||
return render_template('scenes/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/scene/<path:slug>/clone', methods=['POST'])
|
||||
def clone_scene(slug):
|
||||
@@ -538,3 +545,12 @@ def register_routes(app):
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/scene/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_scene_favourite(slug):
|
||||
scene = Scene.query.filter_by(slug=slug).first_or_404()
|
||||
scene.is_favourite = not scene.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': scene.is_favourite}
|
||||
return redirect(url_for('scene_detail', slug=slug))
|
||||
|
||||
209
routes/search.py
Normal file
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':
|
||||
wardrobe = entity.data.get('wardrobe', {})
|
||||
outfit_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||
tags = entity.data.get('tags', [])
|
||||
wardrobe_parts = [v for v in wardrobe.values() if isinstance(v, str) and v]
|
||||
char_parts = []
|
||||
face_parts = []
|
||||
@@ -83,7 +82,7 @@ def register_routes(app):
|
||||
face_parts = [v for v in [identity.get('head'),
|
||||
defaults.get('expression')] if v]
|
||||
hand_parts = [v for v in [wardrobe.get('hands')] if v]
|
||||
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags
|
||||
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts
|
||||
return {
|
||||
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
||||
'face': _dedup_tags(', '.join(face_parts)),
|
||||
@@ -93,7 +92,6 @@ def register_routes(app):
|
||||
if category == 'actions':
|
||||
action_data = entity.data.get('action', {})
|
||||
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||
tags = entity.data.get('tags', [])
|
||||
from utils import _BODY_GROUP_KEYS
|
||||
pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
|
||||
expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
|
||||
@@ -104,7 +102,7 @@ def register_routes(app):
|
||||
identity = character.data.get('identity', {})
|
||||
char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
|
||||
face_parts = [v for v in [identity.get('head')] + expr_parts if v]
|
||||
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
|
||||
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts
|
||||
return {
|
||||
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
|
||||
'face': _dedup_tags(', '.join(face_parts)),
|
||||
@@ -113,20 +111,19 @@ def register_routes(app):
|
||||
|
||||
# styles / scenes / detailers
|
||||
entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
|
||||
tags = entity.data.get('tags', [])
|
||||
|
||||
if category == 'styles':
|
||||
sdata = entity.data.get('style', {})
|
||||
artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else ''
|
||||
style_tags = sdata.get('artistic_style', '')
|
||||
entity_parts = [p for p in [entity_triggers, artist, style_tags] + tags if p]
|
||||
entity_parts = [p for p in [entity_triggers, artist, style_tags] if p]
|
||||
elif category == 'scenes':
|
||||
sdata = entity.data.get('scene', {})
|
||||
scene_parts = [v for v in sdata.values() if isinstance(v, str) and v]
|
||||
entity_parts = [p for p in [entity_triggers] + scene_parts + tags if p]
|
||||
entity_parts = [p for p in [entity_triggers] + scene_parts if p]
|
||||
else: # detailers
|
||||
det_prompt = entity.data.get('prompt', '')
|
||||
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p]
|
||||
entity_parts = [p for p in [entity_triggers, det_prompt] if p]
|
||||
|
||||
char_data_no_lora = _get_character_data_without_lora(character)
|
||||
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''}
|
||||
|
||||
123
routes/styles.py
123
routes/styles.py
@@ -2,7 +2,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import random
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from models import db, Character, Style, Detailer, Settings
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||
from services.sync import sync_styles
|
||||
from services.file_io import get_available_loras
|
||||
@@ -82,8 +81,17 @@ def register_routes(app):
|
||||
|
||||
@app.route('/styles')
|
||||
def styles_index():
|
||||
styles = Style.query.order_by(Style.name).all()
|
||||
return render_template('styles/index.html', styles=styles)
|
||||
query = Style.query
|
||||
fav = request.args.get('favourite')
|
||||
nsfw = request.args.get('nsfw', 'all')
|
||||
if fav == 'on':
|
||||
query = query.filter_by(is_favourite=True)
|
||||
if nsfw == 'sfw':
|
||||
query = query.filter_by(is_nsfw=False)
|
||||
elif nsfw == 'nsfw':
|
||||
query = query.filter_by(is_nsfw=True)
|
||||
styles = query.order_by(Style.is_favourite.desc(), Style.name).all()
|
||||
return render_template('styles/index.html', styles=styles, favourite_filter=fav or '', nsfw_filter=nsfw)
|
||||
|
||||
@app.route('/styles/rescan', methods=['POST'])
|
||||
def rescan_styles():
|
||||
@@ -158,6 +166,13 @@ def register_routes(app):
|
||||
else:
|
||||
new_data.setdefault('lora', {}).pop(bound, None)
|
||||
|
||||
# Update Tags (structured dict)
|
||||
new_data['tags'] = {
|
||||
'style_type': request.form.get('tag_style_type', '').strip(),
|
||||
'nsfw': 'tag_nsfw' in request.form,
|
||||
}
|
||||
style.is_nsfw = new_data['tags']['nsfw']
|
||||
|
||||
style.data = new_data
|
||||
flag_modified(style, "data")
|
||||
|
||||
@@ -343,21 +358,27 @@ def register_routes(app):
|
||||
styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/')
|
||||
_lora_subfolder = os.path.basename(styles_lora_dir)
|
||||
if not os.path.exists(styles_lora_dir):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Styles LoRA directory not found.'}, 400
|
||||
flash('Styles LoRA directory not found.', 'error')
|
||||
return redirect(url_for('styles_index'))
|
||||
|
||||
overwrite = request.form.get('overwrite') == 'true'
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
overwritten_count = 0
|
||||
|
||||
system_prompt = load_prompt('style_system.txt')
|
||||
if not system_prompt:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'error': 'Style system prompt file not found.'}, 500
|
||||
flash('Style system prompt file not found.', 'error')
|
||||
return redirect(url_for('styles_index'))
|
||||
|
||||
for filename in os.listdir(styles_lora_dir):
|
||||
if filename.endswith('.safetensors'):
|
||||
job_ids = []
|
||||
skipped = 0
|
||||
|
||||
for filename in sorted(os.listdir(styles_lora_dir)):
|
||||
if not filename.endswith('.safetensors'):
|
||||
continue
|
||||
|
||||
name_base = filename.rsplit('.', 1)[0]
|
||||
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
|
||||
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
|
||||
@@ -367,11 +388,11 @@ def register_routes(app):
|
||||
|
||||
is_existing = os.path.exists(json_path)
|
||||
if is_existing and not overwrite:
|
||||
skipped_count += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
html_filename = f"{name_base}.html"
|
||||
html_path = os.path.join(styles_lora_dir, html_filename)
|
||||
# Read HTML companion file if it exists
|
||||
html_path = os.path.join(styles_lora_dir, f"{name_base}.html")
|
||||
html_content = ""
|
||||
if os.path.exists(html_path):
|
||||
try:
|
||||
@@ -382,27 +403,28 @@ def register_routes(app):
|
||||
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
|
||||
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
|
||||
html_content = ' '.join(clean_html.split())
|
||||
except Exception as e:
|
||||
print(f"Error reading HTML {html_filename}: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
print(f"Asking LLM to describe style: {style_name}")
|
||||
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{filename}'"
|
||||
if html_content:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
|
||||
def make_task(fn, sid, sname, jp, lsf, html_ctx, sys_prompt, is_exist):
|
||||
def task_fn(job):
|
||||
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{fn}'"
|
||||
if html_ctx:
|
||||
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
|
||||
|
||||
llm_response = call_llm(prompt, system_prompt)
|
||||
llm_response = call_llm(prompt, sys_prompt)
|
||||
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
|
||||
style_data = json.loads(clean_json)
|
||||
|
||||
style_data['style_id'] = style_id
|
||||
style_data['style_name'] = style_name
|
||||
style_data['style_id'] = sid
|
||||
style_data['style_name'] = sname
|
||||
|
||||
if 'lora' not in style_data: style_data['lora'] = {}
|
||||
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
|
||||
if 'lora' not in style_data:
|
||||
style_data['lora'] = {}
|
||||
style_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
|
||||
|
||||
if not style_data['lora'].get('lora_triggers'):
|
||||
style_data['lora']['lora_triggers'] = name_base
|
||||
style_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
|
||||
if style_data['lora'].get('lora_weight') is None:
|
||||
style_data['lora']['lora_weight'] = 1.0
|
||||
if style_data['lora'].get('lora_weight_min') is None:
|
||||
@@ -410,35 +432,43 @@ def register_routes(app):
|
||||
if style_data['lora'].get('lora_weight_max') is None:
|
||||
style_data['lora']['lora_weight_max'] = 1.0
|
||||
|
||||
with open(json_path, 'w') as f:
|
||||
os.makedirs(os.path.dirname(jp), exist_ok=True)
|
||||
with open(jp, 'w') as f:
|
||||
json.dump(style_data, f, indent=2)
|
||||
|
||||
if is_existing:
|
||||
overwritten_count += 1
|
||||
else:
|
||||
created_count += 1
|
||||
job['result'] = {'name': sname, 'action': 'overwritten' if is_exist else 'created'}
|
||||
return task_fn
|
||||
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"Error creating style for {filename}: {e}")
|
||||
job = _enqueue_task(
|
||||
f"Create style: {style_name}",
|
||||
make_task(filename, style_id, style_name, json_path,
|
||||
_lora_subfolder, html_content, system_prompt, is_existing)
|
||||
)
|
||||
job_ids.append(job['id'])
|
||||
|
||||
if created_count > 0 or overwritten_count > 0:
|
||||
# Enqueue a sync task to run after all creates
|
||||
if job_ids:
|
||||
def sync_task(job):
|
||||
sync_styles()
|
||||
msg = f'Successfully processed styles: {created_count} created, {overwritten_count} overwritten.'
|
||||
if skipped_count > 0:
|
||||
msg += f' (Skipped {skipped_count} existing)'
|
||||
flash(msg)
|
||||
else:
|
||||
flash(f'No styles created or overwritten. {skipped_count} existing styles found.')
|
||||
job['result'] = {'synced': True}
|
||||
_enqueue_task("Sync styles DB", sync_task)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
|
||||
|
||||
flash(f'Queued {len(job_ids)} style creation tasks ({skipped} skipped). Watch progress in the queue.')
|
||||
return redirect(url_for('styles_index'))
|
||||
|
||||
@app.route('/style/create', methods=['GET', 'POST'])
|
||||
def create_style():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name')
|
||||
slug = request.form.get('filename', '').strip()
|
||||
|
||||
form_data = {'name': name, 'filename': slug}
|
||||
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
|
||||
|
||||
@@ -483,9 +513,9 @@ def register_routes(app):
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
flash(f"Failed to create style: {e}")
|
||||
return redirect(request.url)
|
||||
return render_template('styles/create.html', form_data=form_data)
|
||||
|
||||
return render_template('styles/create.html')
|
||||
return render_template('styles/create.html', form_data=form_data)
|
||||
|
||||
@app.route('/style/<path:slug>/clone', methods=['POST'])
|
||||
def clone_style(slug):
|
||||
@@ -542,3 +572,12 @@ def register_routes(app):
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/style/<path:slug>/favourite', methods=['POST'])
|
||||
def toggle_style_favourite(slug):
|
||||
style_obj = Style.query.filter_by(slug=slug).first_or_404()
|
||||
style_obj.is_favourite = not style_obj.is_favourite
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return {'success': True, 'is_favourite': style_obj.is_favourite}
|
||||
return redirect(url_for('style_detail', slug=slug))
|
||||
|
||||
@@ -11,13 +11,14 @@ from services.sync import _resolve_preset_entity, _resolve_preset_fields
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
|
||||
def generate_from_preset(preset, overrides=None):
|
||||
def generate_from_preset(preset, overrides=None, save_category='presets'):
|
||||
"""Execute preset-based generation.
|
||||
|
||||
Args:
|
||||
preset: Preset ORM object
|
||||
overrides: optional dict with keys:
|
||||
checkpoint, extra_positive, extra_negative, seed, width, height, action
|
||||
save_category: upload sub-directory ('presets' or 'generator')
|
||||
|
||||
Returns:
|
||||
job dict from _enqueue_job()
|
||||
@@ -52,6 +53,19 @@ def generate_from_preset(preset, overrides=None):
|
||||
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
|
||||
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
|
||||
|
||||
# Build sidecar metadata with resolved entity slugs
|
||||
resolved_meta = {
|
||||
'preset_slug': preset.slug,
|
||||
'preset_name': preset.name,
|
||||
'character_slug': character.slug if character else None,
|
||||
'outfit_slug': outfit.slug if outfit else None,
|
||||
'action_slug': action_obj.slug if action_obj else None,
|
||||
'style_slug': style_obj.slug if style_obj else None,
|
||||
'scene_slug': scene_obj.slug if scene_obj else None,
|
||||
'detailer_slug': detailer_obj.slug if detailer_obj else None,
|
||||
'look_slug': look_obj.slug if look_obj else None,
|
||||
}
|
||||
|
||||
# Checkpoint: override > preset config > default
|
||||
checkpoint_override = overrides.get('checkpoint', '').strip() if overrides.get('checkpoint') else ''
|
||||
if checkpoint_override:
|
||||
@@ -71,14 +85,31 @@ def generate_from_preset(preset, overrides=None):
|
||||
else:
|
||||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||||
|
||||
resolved_meta['checkpoint_path'] = ckpt_path
|
||||
|
||||
# Resolve selected fields from preset toggles
|
||||
selected_fields = _resolve_preset_fields(data)
|
||||
|
||||
# Check suppress_wardrobe: preset override > action default
|
||||
suppress_wardrobe = False
|
||||
preset_suppress = action_cfg.get('suppress_wardrobe')
|
||||
if preset_suppress == 'random':
|
||||
suppress_wardrobe = random.choice([True, False])
|
||||
elif preset_suppress is not None:
|
||||
suppress_wardrobe = bool(preset_suppress)
|
||||
elif action_obj:
|
||||
suppress_wardrobe = action_obj.data.get('suppress_wardrobe', False)
|
||||
|
||||
if suppress_wardrobe:
|
||||
selected_fields = [f for f in selected_fields if not f.startswith('wardrobe::')]
|
||||
|
||||
# Build combined data for prompt building
|
||||
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
|
||||
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
|
||||
if wardrobe_source is None:
|
||||
wardrobe_source = character.get_active_wardrobe() if character else {}
|
||||
if suppress_wardrobe:
|
||||
wardrobe_source = {}
|
||||
|
||||
combined_data = {
|
||||
'character_id': character.character_id if character else 'unknown',
|
||||
@@ -88,7 +119,6 @@ def generate_from_preset(preset, overrides=None):
|
||||
'styles': character.data.get('styles', {}) if character else {},
|
||||
'lora': (look_obj.data.get('lora', {}) if look_obj
|
||||
else (character.data.get('lora', {}) if character else {})),
|
||||
'tags': (character.data.get('tags', []) if character else []) + data.get('tags', []),
|
||||
}
|
||||
|
||||
# Build extras prompt from secondary resources
|
||||
@@ -108,7 +138,6 @@ def generate_from_preset(preset, overrides=None):
|
||||
trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
extras_parts.extend(action_obj.data.get('tags', []))
|
||||
if style_obj:
|
||||
s = style_obj.data.get('style', {})
|
||||
if s.get('artist_name'):
|
||||
@@ -133,7 +162,6 @@ def generate_from_preset(preset, overrides=None):
|
||||
trg = scene_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
extras_parts.extend(scene_obj.data.get('tags', []))
|
||||
if detailer_obj:
|
||||
prompt_val = detailer_obj.data.get('prompt', '')
|
||||
if isinstance(prompt_val, list):
|
||||
@@ -195,6 +223,7 @@ def generate_from_preset(preset, overrides=None):
|
||||
)
|
||||
|
||||
label = f"Preset: {preset.name} – {action}"
|
||||
job = _enqueue_job(label, workflow, _make_finalize('presets', preset.slug, Preset, action))
|
||||
db_model = Preset if save_category == 'presets' else None
|
||||
job = _enqueue_job(label, workflow, _make_finalize(save_category, preset.slug, db_model, action, metadata=resolved_meta))
|
||||
|
||||
return job
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
@@ -27,8 +28,10 @@ logger = logging.getLogger('gaze')
|
||||
|
||||
_job_queue_lock = threading.Lock()
|
||||
_job_queue = deque() # ordered list of job dicts (pending + paused + processing)
|
||||
_llm_queue = deque() # ordered list of LLM task dicts (pending + paused + processing)
|
||||
_job_history = {} # job_id -> job dict (all jobs ever added, for status lookup)
|
||||
_queue_worker_event = threading.Event() # signals worker that a new job is available
|
||||
_llm_worker_event = threading.Event() # signals LLM worker that a new task is available
|
||||
|
||||
# Stored reference to the Flask app, set by init_queue_worker()
|
||||
_app = None
|
||||
@@ -39,6 +42,7 @@ def _enqueue_job(label, workflow, finalize_fn):
|
||||
job = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'label': label,
|
||||
'job_type': 'comfyui',
|
||||
'status': 'pending',
|
||||
'workflow': workflow,
|
||||
'finalize_fn': finalize_fn,
|
||||
@@ -55,6 +59,26 @@ def _enqueue_job(label, workflow, finalize_fn):
|
||||
return job
|
||||
|
||||
|
||||
def _enqueue_task(label, task_fn):
|
||||
"""Add a generic task job (e.g. LLM call) to the LLM queue. Returns the job dict."""
|
||||
job = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'label': label,
|
||||
'job_type': 'llm',
|
||||
'status': 'pending',
|
||||
'task_fn': task_fn,
|
||||
'error': None,
|
||||
'result': None,
|
||||
'created_at': time.time(),
|
||||
}
|
||||
with _job_queue_lock:
|
||||
_llm_queue.append(job)
|
||||
_job_history[job['id']] = job
|
||||
logger.info("LLM task queued: [%s] %s", job['id'][:8], label)
|
||||
_llm_worker_event.set()
|
||||
return job
|
||||
|
||||
|
||||
def _queue_worker():
|
||||
"""Background thread: processes jobs from _job_queue sequentially."""
|
||||
while True:
|
||||
@@ -174,13 +198,14 @@ def _queue_worker():
|
||||
_prune_job_history()
|
||||
|
||||
|
||||
def _make_finalize(category, slug, db_model_class=None, action=None):
|
||||
def _make_finalize(category, slug, db_model_class=None, action=None, metadata=None):
|
||||
"""Return a finalize callback for a standard queue job.
|
||||
|
||||
category — upload sub-directory name (e.g. 'characters', 'outfits')
|
||||
slug — entity slug used for the upload folder name
|
||||
db_model_class — SQLAlchemy model class for cover-image DB update; None = skip
|
||||
action — 'replace' → update DB; None → always update; anything else → skip
|
||||
metadata — optional dict to write as JSON sidecar alongside the image
|
||||
"""
|
||||
def _finalize(comfy_prompt_id, job):
|
||||
logger.debug("=" * 80)
|
||||
@@ -212,6 +237,14 @@ def _make_finalize(category, slug, db_model_class=None, action=None):
|
||||
f.write(image_data)
|
||||
logger.info("Image saved: %s (%d bytes)", full_path, len(image_data))
|
||||
|
||||
# Write JSON sidecar with generation metadata (if provided)
|
||||
if metadata is not None:
|
||||
sidecar_name = filename.rsplit('.', 1)[0] + '.json'
|
||||
sidecar_path = os.path.join(folder, sidecar_name)
|
||||
with open(sidecar_path, 'w') as sf:
|
||||
json.dump(metadata, sf)
|
||||
logger.debug(" Sidecar written: %s", sidecar_path)
|
||||
|
||||
relative_path = f"{category}/{slug}/{filename}"
|
||||
# Include the seed used for this generation
|
||||
used_seed = job['workflow'].get('3', {}).get('inputs', {}).get('seed')
|
||||
@@ -244,6 +277,51 @@ def _make_finalize(category, slug, db_model_class=None, action=None):
|
||||
return _finalize
|
||||
|
||||
|
||||
def _llm_queue_worker():
|
||||
"""Background thread: processes LLM task jobs sequentially."""
|
||||
while True:
|
||||
_llm_worker_event.wait()
|
||||
_llm_worker_event.clear()
|
||||
|
||||
while True:
|
||||
job = None
|
||||
with _job_queue_lock:
|
||||
for j in _llm_queue:
|
||||
if j['status'] == 'pending':
|
||||
job = j
|
||||
break
|
||||
|
||||
if job is None:
|
||||
break
|
||||
|
||||
with _job_queue_lock:
|
||||
job['status'] = 'processing'
|
||||
|
||||
logger.info("LLM task started: [%s] %s", job['id'][:8], job['label'])
|
||||
|
||||
try:
|
||||
with _app.app_context():
|
||||
job['task_fn'](job)
|
||||
|
||||
with _job_queue_lock:
|
||||
job['status'] = 'done'
|
||||
logger.info("LLM task completed: [%s] %s", job['id'][:8], job['label'])
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("LLM task failed: [%s] %s — %s", job['id'][:8], job['label'], e)
|
||||
with _job_queue_lock:
|
||||
job['status'] = 'failed'
|
||||
job['error'] = str(e)
|
||||
|
||||
with _job_queue_lock:
|
||||
try:
|
||||
_llm_queue.remove(job)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
_prune_job_history()
|
||||
|
||||
|
||||
def _prune_job_history(max_age_seconds=3600):
|
||||
"""Remove completed/failed jobs older than max_age_seconds from _job_history."""
|
||||
cutoff = time.time() - max_age_seconds
|
||||
@@ -261,5 +339,5 @@ def init_queue_worker(flask_app):
|
||||
"""
|
||||
global _app
|
||||
_app = flask_app
|
||||
worker = threading.Thread(target=_queue_worker, daemon=True, name='queue-worker')
|
||||
worker.start()
|
||||
threading.Thread(target=_queue_worker, daemon=True, name='comfyui-worker').start()
|
||||
threading.Thread(target=_llm_queue_worker, daemon=True, name='llm-worker').start()
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import json
|
||||
import asyncio
|
||||
import requests
|
||||
from flask import request as flask_request
|
||||
from flask import has_request_context, request as flask_request
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from models import Settings
|
||||
@@ -77,6 +77,28 @@ def call_mcp_tool(name, arguments):
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def _run_character_mcp_tool(name, arguments):
|
||||
server_params = StdioServerParameters(
|
||||
command="docker",
|
||||
args=["run", "--rm", "-i",
|
||||
"-v", "character-cache:/root/.local/share/character_details",
|
||||
"character-mcp:latest"],
|
||||
)
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool(name, arguments)
|
||||
return result.content[0].text
|
||||
|
||||
|
||||
def call_character_mcp_tool(name, arguments):
|
||||
try:
|
||||
return asyncio.run(_run_character_mcp_tool(name, arguments))
|
||||
except Exception as e:
|
||||
print(f"Character MCP Tool Error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_prompt(filename):
|
||||
path = os.path.join('data/prompts', filename)
|
||||
if os.path.exists(path):
|
||||
@@ -100,7 +122,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": flask_request.url_root,
|
||||
"HTTP-Referer": flask_request.url_root if has_request_context() else "http://localhost:5000/",
|
||||
"X-Title": "Character Browser"
|
||||
}
|
||||
model = settings.openrouter_model or 'google/gemini-2.0-flash-001'
|
||||
@@ -120,7 +142,8 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
max_turns = 10
|
||||
max_turns = 15
|
||||
tool_turns_remaining = 8 # stop offering tools after this many tool-calling turns
|
||||
use_tools = True
|
||||
format_retries = 3 # retries allowed for unexpected response format
|
||||
|
||||
@@ -131,13 +154,13 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
# Only add tools if supported/requested
|
||||
if use_tools:
|
||||
# Only add tools if supported/requested and we haven't exhausted tool turns
|
||||
if use_tools and tool_turns_remaining > 0:
|
||||
data["tools"] = DANBOORU_TOOLS
|
||||
data["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
response = requests.post(url, headers=headers, json=data, timeout=120)
|
||||
|
||||
# If 400 Bad Request and we were using tools, try once without tools
|
||||
if response.status_code == 400 and use_tools:
|
||||
@@ -158,6 +181,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
||||
raise KeyError('message')
|
||||
|
||||
if message.get('tool_calls'):
|
||||
tool_turns_remaining -= 1
|
||||
messages.append(message)
|
||||
for tool_call in message['tool_calls']:
|
||||
name = tool_call['function']['name']
|
||||
@@ -170,6 +194,8 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
||||
"name": name,
|
||||
"content": tool_result
|
||||
})
|
||||
if tool_turns_remaining <= 0:
|
||||
print("Tool turn limit reached — next request will not offer tools")
|
||||
continue
|
||||
|
||||
return message['content']
|
||||
|
||||
@@ -171,10 +171,6 @@ def build_prompt(data, selected_fields=None, default_fields=None, active_outfit=
|
||||
if style_data.get('artistic_style') and is_selected('style', 'artistic_style'):
|
||||
parts.append(style_data['artistic_style'])
|
||||
|
||||
tags = data.get('tags', [])
|
||||
if tags and is_selected('special', 'tags'):
|
||||
parts.extend(tags)
|
||||
|
||||
lora = data.get('lora', {})
|
||||
if lora.get('lora_triggers') and is_selected('lora', 'lora_triggers'):
|
||||
parts.append(lora.get('lora_triggers'))
|
||||
@@ -283,7 +279,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
|
||||
lora = data.get('lora', {})
|
||||
if lora.get('lora_triggers'):
|
||||
parts.append(lora['lora_triggers'])
|
||||
parts.extend(data.get('tags', []))
|
||||
for key in _BODY_GROUP_KEYS:
|
||||
val = data.get('action', {}).get(key)
|
||||
if val:
|
||||
@@ -299,7 +294,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
|
||||
lora = data.get('lora', {})
|
||||
if lora.get('lora_triggers'):
|
||||
parts.append(lora['lora_triggers'])
|
||||
parts.extend(data.get('tags', []))
|
||||
|
||||
for scene in scenes:
|
||||
data = scene.data
|
||||
@@ -311,7 +305,6 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers):
|
||||
lora = data.get('lora', {})
|
||||
if lora.get('lora_triggers'):
|
||||
parts.append(lora['lora_triggers'])
|
||||
parts.extend(data.get('tags', []))
|
||||
|
||||
for style in styles:
|
||||
data = style.data
|
||||
|
||||
@@ -14,6 +14,13 @@ from models import (
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
|
||||
def _sync_nsfw_from_tags(entity, data):
|
||||
"""Sync is_nsfw from data['tags']['nsfw'] if tags is a dict. Never touches is_favourite."""
|
||||
tags = data.get('tags')
|
||||
if isinstance(tags, dict):
|
||||
entity.is_nsfw = bool(tags.get('nsfw', False))
|
||||
|
||||
|
||||
def sync_characters():
|
||||
if not os.path.exists(current_app.config['CHARACTERS_DIR']):
|
||||
return
|
||||
@@ -44,6 +51,7 @@ def sync_characters():
|
||||
character.name = name
|
||||
character.slug = slug
|
||||
character.filename = filename
|
||||
_sync_nsfw_from_tags(character, data)
|
||||
|
||||
# Check if cover image still exists
|
||||
if character.image_path:
|
||||
@@ -62,6 +70,7 @@ def sync_characters():
|
||||
name=name,
|
||||
data=data
|
||||
)
|
||||
_sync_nsfw_from_tags(new_char, data)
|
||||
db.session.add(new_char)
|
||||
except Exception as e:
|
||||
print(f"Error importing {filename}: {e}")
|
||||
@@ -102,6 +111,7 @@ def sync_outfits():
|
||||
outfit.name = name
|
||||
outfit.slug = slug
|
||||
outfit.filename = filename
|
||||
_sync_nsfw_from_tags(outfit, data)
|
||||
|
||||
# Check if cover image still exists
|
||||
if outfit.image_path:
|
||||
@@ -120,6 +130,7 @@ def sync_outfits():
|
||||
name=name,
|
||||
data=data
|
||||
)
|
||||
_sync_nsfw_from_tags(new_outfit, data)
|
||||
db.session.add(new_outfit)
|
||||
except Exception as e:
|
||||
print(f"Error importing outfit {filename}: {e}")
|
||||
@@ -243,6 +254,7 @@ def sync_looks():
|
||||
look.slug = slug
|
||||
look.filename = filename
|
||||
look.character_id = character_id
|
||||
_sync_nsfw_from_tags(look, data)
|
||||
|
||||
if look.image_path:
|
||||
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], look.image_path)
|
||||
@@ -259,6 +271,7 @@ def sync_looks():
|
||||
character_id=character_id,
|
||||
data=data
|
||||
)
|
||||
_sync_nsfw_from_tags(new_look, data)
|
||||
db.session.add(new_look)
|
||||
except Exception as e:
|
||||
print(f"Error importing look {filename}: {e}")
|
||||
@@ -418,6 +431,7 @@ def sync_actions():
|
||||
action.name = name
|
||||
action.slug = slug
|
||||
action.filename = filename
|
||||
_sync_nsfw_from_tags(action, data)
|
||||
|
||||
# Check if cover image still exists
|
||||
if action.image_path:
|
||||
@@ -435,6 +449,7 @@ def sync_actions():
|
||||
name=name,
|
||||
data=data
|
||||
)
|
||||
_sync_nsfw_from_tags(new_action, data)
|
||||
db.session.add(new_action)
|
||||
except Exception as e:
|
||||
print(f"Error importing action {filename}: {e}")
|
||||
@@ -475,6 +490,7 @@ def sync_styles():
|
||||
style.name = name
|
||||
style.slug = slug
|
||||
style.filename = filename
|
||||
_sync_nsfw_from_tags(style, data)
|
||||
|
||||
# Check if cover image still exists
|
||||
if style.image_path:
|
||||
@@ -492,6 +508,7 @@ def sync_styles():
|
||||
name=name,
|
||||
data=data
|
||||
)
|
||||
_sync_nsfw_from_tags(new_style, data)
|
||||
db.session.add(new_style)
|
||||
except Exception as e:
|
||||
print(f"Error importing style {filename}: {e}")
|
||||
@@ -532,6 +549,7 @@ def sync_detailers():
|
||||
detailer.name = name
|
||||
detailer.slug = slug
|
||||
detailer.filename = filename
|
||||
_sync_nsfw_from_tags(detailer, data)
|
||||
|
||||
# Check if cover image still exists
|
||||
if detailer.image_path:
|
||||
@@ -549,6 +567,7 @@ def sync_detailers():
|
||||
name=name,
|
||||
data=data
|
||||
)
|
||||
_sync_nsfw_from_tags(new_detailer, data)
|
||||
db.session.add(new_detailer)
|
||||
except Exception as e:
|
||||
print(f"Error importing detailer {filename}: {e}")
|
||||
@@ -589,6 +608,7 @@ def sync_scenes():
|
||||
scene.name = name
|
||||
scene.slug = slug
|
||||
scene.filename = filename
|
||||
_sync_nsfw_from_tags(scene, data)
|
||||
|
||||
# Check if cover image still exists
|
||||
if scene.image_path:
|
||||
@@ -606,6 +626,7 @@ def sync_scenes():
|
||||
name=name,
|
||||
data=data
|
||||
)
|
||||
_sync_nsfw_from_tags(new_scene, data)
|
||||
db.session.add(new_scene)
|
||||
except Exception as e:
|
||||
print(f"Error importing scene {filename}: {e}")
|
||||
@@ -679,19 +700,22 @@ def sync_checkpoints():
|
||||
ckpt.slug = slug
|
||||
ckpt.checkpoint_path = checkpoint_path
|
||||
ckpt.data = data
|
||||
_sync_nsfw_from_tags(ckpt, data)
|
||||
flag_modified(ckpt, "data")
|
||||
if ckpt.image_path:
|
||||
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], ckpt.image_path)
|
||||
if not os.path.exists(full_img_path):
|
||||
ckpt.image_path = None
|
||||
else:
|
||||
db.session.add(Checkpoint(
|
||||
new_ckpt = Checkpoint(
|
||||
checkpoint_id=checkpoint_id,
|
||||
slug=slug,
|
||||
name=display_name,
|
||||
checkpoint_path=checkpoint_path,
|
||||
data=data,
|
||||
))
|
||||
)
|
||||
_sync_nsfw_from_tags(new_ckpt, data)
|
||||
db.session.add(new_ckpt)
|
||||
|
||||
all_ckpts = Checkpoint.query.all()
|
||||
for ckpt in all_ckpts:
|
||||
|
||||
@@ -10,23 +10,23 @@
|
||||
<form action="{{ url_for('create_action') }}" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Action Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Belly Dancing" required>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Belly Dancing" value="{{ form_data.get('name', '') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. belly_dancing">
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. belly_dancing" value="{{ form_data.get('filename', '') }}">
|
||||
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
|
||||
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="prompt-group">
|
||||
<label for="prompt" class="form-label">Description / Concept</label>
|
||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the action, pose, or movement. The AI will generate the full action profile based on this."></textarea>
|
||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the action, pose, or movement. The AI will generate the full action profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
|
||||
<div class="form-text">Required when AI generation is enabled.</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -111,33 +111,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set tags = action.data.tags if action.data.tags is mapping else {} %}
|
||||
{% if tags %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
<span>Tags</span>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
||||
{% if preferences is not none %}
|
||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
||||
{% elif action.default_fields is not none %}
|
||||
{% if 'special::tags' in action.default_fields %}checked{% endif %}
|
||||
{% endif %}>
|
||||
<label class="form-check-label text-white small {% if action.default_fields is not none and 'special::tags' in action.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||
<div class="card-body">
|
||||
{% for tag in action.data.tags %}
|
||||
<span class="badge bg-secondary">{{ tag }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No tags</span>
|
||||
{% endfor %}
|
||||
{% if tags.participants %}<span class="badge bg-info">{{ tags.participants }}</span>{% endif %}
|
||||
{% if action.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||
{% if action.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ action.name }}</h1>
|
||||
<h1 class="mb-0">
|
||||
{{ action.name }}
|
||||
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/action/{{ action.slug }}/favourite" title="Toggle favourite">
|
||||
<span style="font-size:1.2rem;">{% if action.is_favourite %}★{% 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>
|
||||
<form action="{{ url_for('clone_action', slug=action.slug) }}" method="post" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Action</button>
|
||||
@@ -145,6 +141,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('actions', '{{ action.slug }}')">Regenerate Tags</button>
|
||||
<a href="{{ url_for('transfer_resource', category='actions', slug=action.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||
<a href="{{ url_for('actions_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
@@ -299,6 +296,16 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
const data = await resp.json();
|
||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
|
||||
@@ -22,9 +22,24 @@
|
||||
<label for="action_id" class="form-label">Action ID</label>
|
||||
<input type="text" class="form-control" id="action_id" name="action_id" value="{{ action.action_id }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ action.data.tags | join(', ') }}">
|
||||
{% set tags = action.data.tags if action.data.tags is mapping else {} %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="tag_participants" class="form-label">Participants</label>
|
||||
<input type="text" class="form-control" id="tag_participants" name="tag_participants" value="{{ tags.participants or '' }}" placeholder="e.g. solo, 1girl 1boy, 2girls">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label"> </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>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
{% extends "layout.html" %}
|
||||
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Action Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for actions without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all actions" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new action entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_actions_from_loras') }}" method="post" class="d-contents">
|
||||
<input type="hidden" name="overwrite" value="true">
|
||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all action metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL action LoRAs, consuming significant API credits and overwriting ALL existing action metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<a href="{{ url_for('create_action') }}" class="btn btn-sm btn-success">Create New Action</a>
|
||||
<form action="{{ url_for('rescan_actions') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan action files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
||||
</form>
|
||||
</div>
|
||||
{{ library_toolbar(
|
||||
title="Action",
|
||||
category="actions",
|
||||
create_url=url_for('create_action'),
|
||||
create_label="Action",
|
||||
has_batch_gen=true,
|
||||
has_regen_all=true,
|
||||
has_lora_create=true,
|
||||
bulk_create_url=url_for('bulk_create_actions_from_loras'),
|
||||
has_tags=true,
|
||||
regen_tags_category="actions",
|
||||
rescan_url=url_for('rescan_actions'),
|
||||
get_missing_url="/get_missing_actions",
|
||||
clear_covers_url="/clear_all_action_covers",
|
||||
generate_url_pattern="/action/{slug}/generate"
|
||||
) }}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||
</div>
|
||||
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||
{% for action in actions %}
|
||||
@@ -40,7 +52,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ action.name }}</h5>
|
||||
<h5 class="card-title text-center">{% if action.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ action.name }}{% if action.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||
<p class="card-text small text-center text-muted">
|
||||
{% set ns = namespace(parts=[]) %}
|
||||
{% if action.data.action is mapping %}
|
||||
@@ -80,111 +92,11 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle highlight parameter
|
||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||
if (highlightSlug) {
|
||||
const card = document.getElementById(`card-${highlightSlug}`);
|
||||
if (card) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const itemNameText = document.getElementById('current-item-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
||||
else nodeStatus.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
const response = await fetch('/get_missing_actions');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert("No actions missing cover images.");
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs upfront
|
||||
|
||||
const jobs = [];
|
||||
for (const item of missing) {
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/action/${item.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${item.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all concurrently
|
||||
let currentItem = '';
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
currentItem = item.name;
|
||||
itemNameText.textContent = `Processing: ${currentItem}`;
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} action images processed.`);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_actions');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) {
|
||||
alert("No actions missing cover images.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} actions?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm("This will unassign ALL current action cover images and generate new ones. Proceed?")) return;
|
||||
|
||||
const clearResp = await fetch('/clear_all_action_covers', { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -106,7 +106,12 @@
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ ckpt.name }}</h1>
|
||||
<h1 class="mb-0">
|
||||
{{ ckpt.name }}
|
||||
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/checkpoint/{{ ckpt.slug }}/favourite" title="Toggle favourite">
|
||||
<span style="font-size:1.2rem;">{% if ckpt.is_favourite %}★{% else %}☆{% endif %}</span>
|
||||
</button>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
@@ -232,6 +237,16 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
const data = await resp.json();
|
||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Checkpoint Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for checkpoints without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all checkpoints" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new checkpoint entries from all checkpoint files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_checkpoints') }}" method="post" class="d-contents">
|
||||
<input type="hidden" name="overwrite" value="true">
|
||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all checkpoint metadata (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL checkpoints, consuming API credits and overwriting ALL existing metadata. Are you sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<form action="{{ url_for('rescan_checkpoints') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan checkpoint files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
||||
</form>
|
||||
</div>
|
||||
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||
{{ library_toolbar(
|
||||
title="Checkpoint",
|
||||
category="checkpoints",
|
||||
create_url=none,
|
||||
has_batch_gen=true,
|
||||
has_regen_all=true,
|
||||
has_lora_create=true,
|
||||
bulk_create_url=url_for('bulk_create_checkpoints'),
|
||||
has_tags=false,
|
||||
rescan_url=url_for('rescan_checkpoints'),
|
||||
get_missing_url="/get_missing_checkpoints",
|
||||
clear_covers_url="/clear_all_checkpoint_covers",
|
||||
generate_url_pattern="/checkpoint/{slug}/generate"
|
||||
) }}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||
</div>
|
||||
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||
{% for ckpt in checkpoints %}
|
||||
@@ -39,7 +50,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ ckpt.name }}</h5>
|
||||
<h5 class="card-title text-center">
|
||||
{% if ckpt.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ ckpt.name }}
|
||||
{% if ckpt.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center p-1">
|
||||
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
|
||||
@@ -53,101 +67,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const ckptNameText = document.getElementById('current-ckpt-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
let currentJobId = null;
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
||||
else nodeStatus.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
const response = await fetch('/get_missing_checkpoints');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert('No checkpoints missing cover images.');
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs upfront
|
||||
|
||||
const jobs = [];
|
||||
for (const ckpt of missing) {
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ character_slug: '__random__' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item: ckpt, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${ckpt.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all concurrently
|
||||
let currentItem = '';
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
currentItem = item.name;
|
||||
ckptNameText.textContent = `Processing: ${currentItem}`;
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} checkpoint images processed.`);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_checkpoints');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) { alert('No checkpoints missing cover images.'); return; }
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} checkpoints?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm('This will unassign ALL current checkpoint cover images and generate new ones. Proceed?')) return;
|
||||
const clearResp = await fetch('/clear_all_checkpoint_covers', { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,26 +10,32 @@
|
||||
<form action="{{ url_for('create_character') }}" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Character Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Cyberpunk Ninja" required>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Cyberpunk Ninja" value="{{ form_data.get('name', '') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. cyberpunk_ninja">
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. cyberpunk_ninja" value="{{ form_data.get('filename', '') }}">
|
||||
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
|
||||
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="prompt-group">
|
||||
<label for="prompt" class="form-label">Description / Concept</label>
|
||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the character's appearance, clothing, style, and personality. The AI will generate the full profile based on this."></textarea>
|
||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the character's appearance, clothing, style, and personality. The AI will generate the full profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
|
||||
<div class="form-text">Required when AI generation is enabled.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="wiki-url-group">
|
||||
<label for="wiki_url" class="form-label">Wiki / Reference URL <small class="text-muted">- optional</small></label>
|
||||
<input type="url" class="form-control" id="wiki_url" name="wiki_url" placeholder="e.g. https://finalfantasy.fandom.com/wiki/Tifa_Lockhart" value="{{ form_data.get('wiki_url', '') }}">
|
||||
<div class="form-text">Fandom wiki URL or other character page. The AI will use this as reference for accurate appearance details.</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" id="ai-info">
|
||||
<i class="bi bi-info-circle"></i> The AI will generate a complete character profile based on your description.
|
||||
</div>
|
||||
@@ -51,6 +57,7 @@
|
||||
<script>
|
||||
document.getElementById('use_llm').addEventListener('change', function() {
|
||||
const promptGroup = document.getElementById('prompt-group');
|
||||
const wikiUrlGroup = document.getElementById('wiki-url-group');
|
||||
const aiInfo = document.getElementById('ai-info');
|
||||
const manualInfo = document.getElementById('manual-info');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
@@ -58,12 +65,14 @@ document.getElementById('use_llm').addEventListener('change', function() {
|
||||
|
||||
if (this.checked) {
|
||||
promptGroup.classList.remove('d-none');
|
||||
wikiUrlGroup.classList.remove('d-none');
|
||||
aiInfo.classList.remove('d-none');
|
||||
manualInfo.classList.add('d-none');
|
||||
submitBtn.textContent = 'Create & Generate';
|
||||
promptInput.required = true;
|
||||
} else {
|
||||
promptGroup.classList.add('d-none');
|
||||
wikiUrlGroup.classList.add('d-none');
|
||||
aiInfo.classList.add('d-none');
|
||||
manualInfo.classList.remove('d-none');
|
||||
submitBtn.textContent = 'Create Character';
|
||||
|
||||
@@ -59,31 +59,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set tags = character.data.tags if character.data.tags is mapping else {} %}
|
||||
{% if tags %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
<span>Tags</span>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
||||
{% if preferences is not none %}
|
||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
||||
{% elif character.default_fields is not none %}
|
||||
{% if 'special::tags' in character.default_fields %}checked{% endif %}
|
||||
{% endif %}>
|
||||
<label class="form-check-label text-white small {% if character.default_fields is not none and 'special::tags' in character.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||
<div class="card-body">
|
||||
{% for tag in character.data.tags %}
|
||||
<span class="badge bg-secondary">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% if tags.origin_series %}<span class="badge bg-info">{{ tags.origin_series }}</span>{% endif %}
|
||||
{% if tags.origin_type %}<span class="badge bg-primary">{{ tags.origin_type }}</span>{% endif %}
|
||||
{% if character.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||
{% if character.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ character.name }}</h1>
|
||||
<h1 class="mb-0">
|
||||
{{ character.name }}
|
||||
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/character/{{ character.slug }}/favourite" title="Toggle favourite">
|
||||
<span style="font-size:1.2rem;">{% if character.is_favourite %}★{% 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">
|
||||
<a href="{{ url_for('edit_character', slug=character.slug) }}" class="btn btn-sm btn-link text-decoration-none">Edit Profile</a>
|
||||
<a href="{{ url_for('transfer_character', slug=character.slug) }}" class="btn btn-sm btn-warning text-decoration-none">
|
||||
@@ -91,8 +90,11 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('characters', '{{ character.slug }}')">Regenerate Tags</button>
|
||||
<a href="/" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-4" id="detailTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
@@ -271,6 +273,16 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
const data = await resp.json();
|
||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<form action="{{ url_for('create_detailer') }}" method="post">
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-bold">Detailer Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Eye Detail Glossy" required>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Eye Detail Glossy" value="{{ form_data.get('name', '') }}" required>
|
||||
<div class="form-text">The display name for the detailer gallery.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="filename" class="form-label fw-bold">Detailer ID / Filename <small class="text-muted">(Optional)</small></label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. eye_detail_glossy">
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. eye_detail_glossy" value="{{ form_data.get('filename', '') }}">
|
||||
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,13 +127,20 @@
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ detailer.name }}</h1>
|
||||
<h1 class="mb-0">
|
||||
{{ detailer.name }}
|
||||
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/detailer/{{ detailer.slug }}/favourite" title="Toggle favourite">
|
||||
<span style="font-size:1.2rem;">{% if detailer.is_favourite %}★{% 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">
|
||||
<a href="{{ url_for('edit_detailer', slug=detailer.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Detailer</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('detailers', '{{ detailer.slug }}')">Regenerate Tags</button>
|
||||
<a href="{{ url_for('transfer_resource', category='detailers', slug=detailer.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||
<a href="{{ url_for('detailers_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
@@ -260,6 +267,16 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
const data = await resp.json();
|
||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
|
||||
@@ -74,11 +74,34 @@
|
||||
value="{{ detailer.data.prompt or '' }}">
|
||||
<div class="form-text">Comma-separated tags, e.g. "glossy eyes, detailed irises"</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Extra Tags</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags"
|
||||
value="{{ detailer.data.tags | join(', ') if detailer.data.tags else '' }}">
|
||||
<div class="form-text">Comma-separated extra tags appended to every generation.</div>
|
||||
{% set tags = detailer.data.tags if detailer.data.tags is mapping else {} %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="tag_associated_resource" class="form-label">Associated Resource</label>
|
||||
<select class="form-select" id="tag_associated_resource" name="tag_associated_resource">
|
||||
{% for opt in ['', 'General', 'Looks', 'Styles', 'Faces', 'NSFW'] %}
|
||||
<option value="{{ opt }}" {% if tags.associated_resource == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5 mb-3">
|
||||
<label class="form-label">ADetailer Targets</label>
|
||||
<div>
|
||||
{% for target in ['face', 'hands', 'body', 'nsfw'] %}
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="tag_target_{{ target }}" name="tag_adetailer_targets" value="{{ target }}" {% if tags.adetailer_targets is defined and target in tags.adetailer_targets %}checked{% endif %}>
|
||||
<label class="form-check-label" for="tag_target_{{ target }}">{{ target|capitalize }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label"> </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>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
{% extends "layout.html" %}
|
||||
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Detailer Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for detailers without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all detailers" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new detailer entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_detailers_from_loras') }}" method="post" class="d-contents">
|
||||
<input type="hidden" name="overwrite" value="true">
|
||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all detailer metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL detailer LoRAs, consuming significant API credits and overwriting ALL existing detailer metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<a href="{{ url_for('create_detailer') }}" class="btn btn-sm btn-success">Create New Detailer</a>
|
||||
<form action="{{ url_for('rescan_detailers') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan detailer files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
||||
</form>
|
||||
</div>
|
||||
{{ library_toolbar(
|
||||
title="Detailer",
|
||||
category="detailers",
|
||||
create_url=url_for('create_detailer'),
|
||||
create_label="Detailer",
|
||||
has_batch_gen=true,
|
||||
has_regen_all=true,
|
||||
has_lora_create=true,
|
||||
bulk_create_url=url_for('bulk_create_detailers_from_loras'),
|
||||
has_tags=true,
|
||||
regen_tags_category="detailers",
|
||||
rescan_url=url_for('rescan_detailers'),
|
||||
get_missing_url="/get_missing_detailers",
|
||||
clear_covers_url="/clear_all_detailer_covers",
|
||||
generate_url_pattern="/detailer/{slug}/generate"
|
||||
) }}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||
</div>
|
||||
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||
{% for detailer in detailers %}
|
||||
@@ -40,7 +52,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ detailer.name }}</h5>
|
||||
<h5 class="card-title text-center">{% if detailer.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ detailer.name }}{% if detailer.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||
<p class="card-text small text-center text-muted">
|
||||
{% set ns = namespace(parts=[]) %}
|
||||
{% if detailer.data.prompt is string %}
|
||||
@@ -82,111 +94,11 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle highlight parameter
|
||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||
if (highlightSlug) {
|
||||
const card = document.getElementById(`card-${highlightSlug}`);
|
||||
if (card) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const detailerNameText = document.getElementById('current-detailer-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
||||
else nodeStatus.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
const response = await fetch('/get_missing_detailers');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert("No detailers missing cover images.");
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs upfront
|
||||
|
||||
const jobs = [];
|
||||
for (const item of missing) {
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/detailer/${item.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${item.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all concurrently
|
||||
let currentItem = '';
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
currentItem = item.name;
|
||||
detailerNameText.textContent = `Processing: ${currentItem}`;
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} detailer images processed.`);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_detailers');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) {
|
||||
alert("No detailers missing cover images.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} detailers?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm("This will unassign ALL current detailer cover images and generate new ones. Proceed?")) return;
|
||||
|
||||
const clearResp = await fetch('/clear_all_detailer_covers', { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,9 +18,27 @@
|
||||
<label for="character_name" class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="character_name" name="character_name" value="{{ character.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ character.data.tags | join(', ') }}">
|
||||
{% set tags = character.data.tags if character.data.tags is mapping else {} %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="tag_origin_series" class="form-label">Origin Series</label>
|
||||
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="tag_origin_type" class="form-label">Origin Type</label>
|
||||
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
|
||||
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
|
||||
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label"> </label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if character.is_nsfw %}checked{% endif %}>
|
||||
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +139,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set wardrobe_data = character.data.wardrobe %}
|
||||
{% set wardrobe_data = character.data.get('wardrobe', {}) %}
|
||||
{% set outfits = character.get_available_outfits() %}
|
||||
{% if wardrobe_data.default is defined and wardrobe_data.default is mapping %}
|
||||
{# New nested format - show tabs for each outfit #}
|
||||
|
||||
@@ -57,23 +57,50 @@
|
||||
</select>
|
||||
</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 -->
|
||||
<div class="col">
|
||||
{% if category != 'all' %}
|
||||
<span class="badge bg-primary me-1">
|
||||
{{ category | capitalize }}
|
||||
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||
<a href="{{ url_for('gallery', sort=sort, per_page=per_page, xref_category=xref_category, xref_slug=xref_slug) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if slug %}
|
||||
<span class="badge bg-secondary me-1">
|
||||
{{ slug }}
|
||||
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page, xref_category=xref_category, xref_slug=xref_slug) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if xref_category and xref_slug %}
|
||||
<span class="badge bg-info me-1">
|
||||
Cross-ref: {{ xref_category | capitalize }} = {{ xref_slug }}
|
||||
<a href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="page" value="1">
|
||||
{% if xref_category %}<input type="hidden" name="xref_category" value="{{ xref_category }}">{% endif %}
|
||||
{% if xref_slug %}<input type="hidden" name="xref_slug" value="{{ xref_slug }}">{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -117,6 +144,17 @@
|
||||
class="badge {% if category == 'checkpoints' %}bg-dark{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2">
|
||||
Checkpoints
|
||||
</a>
|
||||
<span class="text-muted small mx-1">|</span>
|
||||
<a href="{{ url_for('gallery', category='presets', sort=sort, per_page=per_page) }}"
|
||||
class="badge {% if category == 'presets' %}text-white{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"
|
||||
{% if category == 'presets' %}style="background-color: #6f42c1"{% endif %}>
|
||||
Presets
|
||||
</a>
|
||||
<a href="{{ url_for('gallery', category='generator', sort=sort, per_page=per_page) }}"
|
||||
class="badge {% if category == 'generator' %}text-white{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"
|
||||
{% if category == 'generator' %}style="background-color: #20c997"{% endif %}>
|
||||
Generator
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,6 +260,9 @@
|
||||
'styles': 'warning',
|
||||
'detailers': 'secondary',
|
||||
'checkpoints': 'dark',
|
||||
'looks': 'primary',
|
||||
'presets': 'purple',
|
||||
'generator': 'teal',
|
||||
} %}
|
||||
{% for img in images %}
|
||||
<div class="gallery-card"
|
||||
@@ -237,6 +278,8 @@
|
||||
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
|
||||
{{ img.category[:-1] if img.category.endswith('s') else img.category }}
|
||||
</span>
|
||||
{% if img._sidecar.get('is_favourite') %}<span class="fav-badge" title="Favourite" onclick="event.stopPropagation(); toggleImageFavourite('{{ img.path }}', this)">★</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 -->
|
||||
<div class="info-meta">
|
||||
@@ -280,8 +323,12 @@
|
||||
<a href="{{ url_for('checkpoint_detail', slug=img.slug) }}"
|
||||
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||
onclick="event.stopPropagation()">Open</a>
|
||||
{% elif img.category in ('presets', 'generator') %}
|
||||
<a href="{{ url_for('preset_detail', slug=img.slug) }}"
|
||||
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||
onclick="event.stopPropagation()">Preset</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('generator') }}?{{ img.category[:-1] }}={{ img.slug }}"
|
||||
<a href="{{ url_for('generator') }}?preset={{ img.slug }}"
|
||||
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||
onclick="event.stopPropagation()">Generator</a>
|
||||
{% endif %}
|
||||
@@ -376,6 +423,12 @@
|
||||
<div class="meta-grid" id="metaGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Cross-reference entity chips (from sidecar) -->
|
||||
<div class="mb-3 d-none" id="xrefRow">
|
||||
<label class="form-label fw-semibold mb-1">Used Entities</label>
|
||||
<div class="d-flex flex-wrap gap-1" id="xrefContainer"></div>
|
||||
</div>
|
||||
|
||||
<div id="noMetaMsg" class="d-none">
|
||||
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
|
||||
</div>
|
||||
@@ -409,7 +462,31 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.fav-badge { position: absolute; top: 4px; left: 4px; font-size: 1.2rem; color: #ffc107; cursor: pointer; text-shadow: 0 0 3px rgba(0,0,0,0.7); z-index: 2; }
|
||||
.fav-badge.fav-off { color: rgba(255,255,255,0.5); }
|
||||
.fav-badge:hover { transform: scale(1.2); }
|
||||
</style>
|
||||
<script>
|
||||
async function toggleImageFavourite(path, el) {
|
||||
const resp = await fetch('/gallery/image/favourite', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({path})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
el.innerHTML = data.is_favourite ? '★' : '☆';
|
||||
el.classList.toggle('fav-off', !data.is_favourite);
|
||||
}
|
||||
}
|
||||
async function toggleImageNsfw(path, el) {
|
||||
const resp = await fetch('/gallery/image/nsfw', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({path})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) location.reload();
|
||||
}
|
||||
// ---- Prompt modal ----
|
||||
let promptModal;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -473,15 +550,55 @@ async function showPrompt(imgPath, name, category, slug) {
|
||||
document.getElementById('noMetaMsg').classList.toggle('d-none',
|
||||
!!(data.positive || loras.length || hasParams));
|
||||
|
||||
// Cross-reference entity chips (from sidecar)
|
||||
const xrefContainer = document.getElementById('xrefContainer');
|
||||
const xrefRow = document.getElementById('xrefRow');
|
||||
xrefContainer.innerHTML = '';
|
||||
if (data.sidecar) {
|
||||
const sc = data.sidecar;
|
||||
const xrefColors = {
|
||||
character: 'primary', outfit: 'success', action: 'danger',
|
||||
style: 'warning', scene: 'info', detailer: 'secondary',
|
||||
look: 'primary', preset: 'purple'
|
||||
};
|
||||
for (const [key, sidecarKey] of [
|
||||
['character', 'character_slug'], ['outfit', 'outfit_slug'],
|
||||
['action', 'action_slug'], ['style', 'style_slug'],
|
||||
['scene', 'scene_slug'], ['detailer', 'detailer_slug'],
|
||||
['look', 'look_slug'], ['preset', 'preset_slug']
|
||||
]) {
|
||||
const val = sc[sidecarKey];
|
||||
if (!val) continue;
|
||||
const chip = document.createElement('a');
|
||||
chip.className = `badge bg-${xrefColors[key] || 'secondary'} text-decoration-none`;
|
||||
chip.href = `/gallery?xref_category=${key}&xref_slug=${encodeURIComponent(val)}`;
|
||||
chip.textContent = `${key}: ${sc[sidecarKey.replace('_slug', '_name')] || val}`;
|
||||
chip.title = `Show all images using this ${key}`;
|
||||
xrefContainer.appendChild(chip);
|
||||
}
|
||||
xrefRow.classList.toggle('d-none', xrefContainer.children.length === 0);
|
||||
} else {
|
||||
xrefRow.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Generator link
|
||||
const genUrl = category === 'characters'
|
||||
? `/character/${slug}`
|
||||
: category === 'checkpoints'
|
||||
? `/checkpoint/${slug}`
|
||||
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
|
||||
let genUrl, genLabel;
|
||||
if (category === 'characters') {
|
||||
genUrl = `/character/${slug}`;
|
||||
genLabel = 'Open';
|
||||
} else if (category === 'checkpoints') {
|
||||
genUrl = `/checkpoint/${slug}`;
|
||||
genLabel = 'Open';
|
||||
} else if (category === 'presets' || category === 'generator') {
|
||||
genUrl = `/generator?preset=${encodeURIComponent(slug)}`;
|
||||
genLabel = 'Open in Generator';
|
||||
} else {
|
||||
genUrl = `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
|
||||
genLabel = 'Open in Generator';
|
||||
}
|
||||
const genBtn = document.getElementById('openGeneratorBtn');
|
||||
genBtn.href = genUrl;
|
||||
genBtn.textContent = (category === 'characters' || category === 'checkpoints') ? 'Open' : 'Open in Generator';
|
||||
genBtn.textContent = genLabel;
|
||||
} catch (e) {
|
||||
document.getElementById('promptPositive').value = 'Error loading metadata.';
|
||||
} finally {
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
<div class="col-md-5">
|
||||
<div id="progress-container" class="mb-3 d-none">
|
||||
<label id="progress-label" class="form-label">Generating...</label>
|
||||
<div class="progress" role="progressbar" aria-label="Generation Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress" role="progressbar">
|
||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">Generator Settings</div>
|
||||
<div class="card-header bg-primary text-white">Generator</div>
|
||||
<div class="card-body">
|
||||
<form id="generator-form" action="{{ url_for('generator') }}" method="post">
|
||||
<form id="generator-form" action="{{ url_for('generator_generate') }}" method="post">
|
||||
|
||||
<!-- Controls bar -->
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-3 pb-3 border-bottom">
|
||||
@@ -34,184 +34,94 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Character -->
|
||||
<!-- Preset selector -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label for="character" class="form-label mb-0">Character</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-char-btn">Random</button>
|
||||
<label for="preset-select" class="form-label mb-0 fw-semibold">Preset</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-preset-btn">Random</button>
|
||||
</div>
|
||||
<select class="form-select" id="character" name="character" required>
|
||||
<option value="" disabled {% if not selected_char %}selected{% endif %}>Select a character...</option>
|
||||
{% for char in characters %}
|
||||
<option value="{{ char.slug }}" {% if selected_char == char.slug %}selected{% endif %}>{{ char.name }}</option>
|
||||
<select class="form-select" id="preset-select" name="preset_slug" required>
|
||||
<option value="" disabled {% if not preset_slug %}selected{% endif %}>Select a preset...</option>
|
||||
{% for p in presets %}
|
||||
<option value="{{ p.slug }}" {% if preset_slug == p.slug %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Checkpoint -->
|
||||
<!-- Preset summary (populated via AJAX) -->
|
||||
<div class="mb-3" id="preset-summary-container" style="display:none">
|
||||
<label class="form-label mb-1 small fw-semibold text-muted">Preset Configuration</label>
|
||||
<div class="d-flex flex-wrap gap-1" id="preset-summary"></div>
|
||||
</div>
|
||||
|
||||
<!-- Checkpoint override -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label for="checkpoint" class="form-label mb-0">Checkpoint Model</label>
|
||||
<label for="checkpoint" class="form-label mb-0">Checkpoint Override</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-ckpt-btn">Random</button>
|
||||
</div>
|
||||
<select class="form-select" id="checkpoint" name="checkpoint" required>
|
||||
<select class="form-select form-select-sm" id="checkpoint" name="checkpoint">
|
||||
<option value="">Use preset default</option>
|
||||
{% for ckpt in checkpoints %}
|
||||
<option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt }}</option>
|
||||
<option value="{{ ckpt }}" {% if selected_ckpt == ckpt %}selected{% endif %}>{{ ckpt.split('/')[-1] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Listing models from Illustrious/ folder</div>
|
||||
</div>
|
||||
|
||||
<!-- Mix & Match -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Mix & Match
|
||||
<small class="text-muted fw-normal ms-1">— first checked per category applies its LoRA</small>
|
||||
</label>
|
||||
<div class="accordion" id="mixAccordion">
|
||||
{% set mix_categories = [
|
||||
('Actions', 'action', actions, 'action_slugs'),
|
||||
('Outfits', 'outfit', outfits, 'outfit_slugs'),
|
||||
('Scenes', 'scene', scenes, 'scene_slugs'),
|
||||
('Styles', 'style', styles, 'style_slugs'),
|
||||
('Detailers', 'detailer', detailers, 'detailer_slugs'),
|
||||
] %}
|
||||
{% for cat_label, cat_key, cat_items, field_name in mix_categories %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed py-2" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#mix-{{ cat_key }}">
|
||||
{{ cat_label }}
|
||||
<span class="badge bg-secondary rounded-pill ms-2" id="badge-{{ cat_key }}">0</span>
|
||||
<span class="badge bg-light text-secondary border ms-2 px-2 py-1"
|
||||
style="cursor:pointer;font-size:.7rem;font-weight:normal"
|
||||
onclick="event.stopPropagation(); randomizeCategory('{{ field_name }}', '{{ cat_key }}')">Random</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="mix-{{ cat_key }}" class="accordion-collapse collapse">
|
||||
<div class="accordion-body p-2">
|
||||
<input type="text" class="form-control form-control-sm mb-2"
|
||||
placeholder="Search {{ cat_label | lower }}..."
|
||||
oninput="filterMixCategory(this, 'mixlist-{{ cat_key }}')">
|
||||
<div id="mixlist-{{ cat_key }}" style="max-height:220px;overflow-y:auto;">
|
||||
{% for item in cat_items %}
|
||||
<label class="mix-item d-flex align-items-center gap-2 px-2 py-1 rounded"
|
||||
data-name="{{ item.name | lower }}" style="cursor:pointer;">
|
||||
<input type="checkbox" class="form-check-input flex-shrink-0"
|
||||
name="{{ field_name }}" value="{{ item.slug }}"
|
||||
onchange="updateMixBadge('{{ cat_key }}', '{{ field_name }}')">
|
||||
{% if item.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + item.image_path) }}"
|
||||
class="rounded flex-shrink-0" style="width:32px;height:32px;object-fit:cover">
|
||||
{% else %}
|
||||
<span class="rounded bg-light flex-shrink-0 d-inline-flex align-items-center justify-content-center text-muted"
|
||||
style="width:32px;height:32px;font-size:9px;">N/A</span>
|
||||
{% endif %}
|
||||
<span class="small text-truncate">{{ item.name }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="text-muted small p-2 mb-0">No {{ cat_label | lower }} found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Resolution</label>
|
||||
<label class="form-label">Resolution Override</label>
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary preset-btn" data-w="1024" data-h="1024">1:1</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1152" data-h="896">4:3 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="896" data-h="1152">4:3 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1344" data-h="768">16:9 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1344">16:9 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1280" data-h="800">16:10 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="800" data-h="1280">16:10 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="1792" data-h="768">21:9 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preset-btn" data-w="768" data-h="1792">21:9 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="" data-h="">Preset default</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1024" data-h="1024">1:1</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1152" data-h="896">4:3 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="896" data-h="1152">4:3 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1344" data-h="768">16:9 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="768" data-h="1344">16:9 P</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="1280" data-h="800">16:10 L</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary res-btn" data-w="800" data-h="1280">16:10 P</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="form-label mb-0 small fw-semibold">W</label>
|
||||
<input type="number" class="form-control form-control-sm" name="width" id="res-width"
|
||||
value="1024" min="64" max="4096" step="64" style="width:88px">
|
||||
<span class="text-muted">×</span>
|
||||
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
|
||||
<span class="text-muted">×</span>
|
||||
<label class="form-label mb-0 small fw-semibold">H</label>
|
||||
<input type="number" class="form-control form-control-sm" name="height" id="res-height"
|
||||
value="1024" min="64" max="4096" step="64" style="width:88px">
|
||||
value="" min="64" max="4096" step="64" style="width:88px" placeholder="Auto">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Preview -->
|
||||
<!-- Extra prompts -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="form-label mb-0">Prompt Preview</label>
|
||||
<div class="d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="build-preview-btn">Build</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-preview-btn">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-widget-container d-none" id="prompt-tags"></div>
|
||||
<textarea class="form-control form-control-sm font-monospace" id="prompt-preview"
|
||||
name="override_prompt" rows="5"
|
||||
placeholder="Click Build to preview the auto-generated prompt — edit freely before generating."></textarea>
|
||||
<div class="form-text" id="preview-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- ADetailer Prompt Previews -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label mb-0">Face Detailer Prompt</label>
|
||||
<div class="tag-widget-container d-none" id="face-tags"></div>
|
||||
<textarea class="form-control form-control-sm font-monospace" id="face-preview"
|
||||
name="override_face_prompt" rows="2"
|
||||
placeholder="Auto-populated on Build — edit to override face detailer prompt."></textarea>
|
||||
<label for="extra_positive" class="form-label">Extra Positive Prompt</label>
|
||||
<textarea class="form-control form-control-sm" id="extra_positive" name="extra_positive" rows="2"
|
||||
placeholder="Additional tags appended to the preset prompt"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label mb-0">Hand Detailer Prompt</label>
|
||||
<div class="tag-widget-container d-none" id="hand-tags"></div>
|
||||
<textarea class="form-control form-control-sm font-monospace" id="hand-preview"
|
||||
name="override_hand_prompt" rows="2"
|
||||
placeholder="Auto-populated on Build — edit to override hand detailer prompt."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Additional prompts -->
|
||||
<div class="mb-3">
|
||||
<label for="positive_prompt" class="form-label">Additional Positive Prompt</label>
|
||||
<textarea class="form-control" id="positive_prompt" name="positive_prompt" rows="2" placeholder="e.g. sitting in a cafe, drinking coffee, daylight"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="negative_prompt" class="form-label">Additional Negative Prompt</label>
|
||||
<textarea class="form-control" id="negative_prompt" name="negative_prompt" rows="2" placeholder="e.g. bad hands, extra digits"></textarea>
|
||||
<label for="extra_negative" class="form-label">Extra Negative Prompt</label>
|
||||
<textarea class="form-control form-control-sm" id="extra_negative" name="extra_negative" rows="2"
|
||||
placeholder="Additional negative tags"></textarea>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white">Result</div>
|
||||
<div class="card-body p-0 d-flex align-items-center justify-content-center" style="min-height: 500px;" id="result-container">
|
||||
{% if generated_image %}
|
||||
<div class="img-container w-100 h-100">
|
||||
<img src="{{ url_for('static', filename='uploads/' + generated_image) }}" alt="Generated Result" class="img-fluid w-100" id="result-img">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted" id="placeholder-text">
|
||||
<p>Select settings and click Generate</p>
|
||||
<p>Select a preset and click Generate</p>
|
||||
</div>
|
||||
<div class="img-container w-100 h-100 d-none">
|
||||
<img src="" alt="Generated Result" class="img-fluid w-100" id="result-img">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer d-none" id="result-footer">
|
||||
<small class="text-muted">Saved to character gallery</small>
|
||||
<small class="text-muted">Saved to generator gallery</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,60 +131,43 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// --- Filtering ---
|
||||
function filterMixCategory(input, listId) {
|
||||
const query = input.value.toLowerCase();
|
||||
document.querySelectorAll(`#${listId} .mix-item`).forEach(el => {
|
||||
el.style.display = el.dataset.name.includes(query) ? '' : 'none';
|
||||
});
|
||||
// --- Preset summary ---
|
||||
async function loadPresetInfo(slug) {
|
||||
const container = document.getElementById('preset-summary-container');
|
||||
const summary = document.getElementById('preset-summary');
|
||||
if (!slug) { container.style.display = 'none'; return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`/generator/preset_info?slug=${encodeURIComponent(slug)}`);
|
||||
const data = await res.json();
|
||||
summary.innerHTML = '';
|
||||
|
||||
const colors = {
|
||||
character: 'primary', outfit: 'success', action: 'danger',
|
||||
style: 'warning', scene: 'info', detailer: 'secondary',
|
||||
look: 'primary', checkpoint: 'dark', resolution: 'light text-dark'
|
||||
};
|
||||
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (val === null) continue;
|
||||
const color = colors[key] || 'secondary';
|
||||
const chip = document.createElement('span');
|
||||
chip.className = `badge bg-${color} me-1 mb-1`;
|
||||
chip.innerHTML = `<small class="opacity-75">${key}:</small> ${val}`;
|
||||
summary.appendChild(chip);
|
||||
}
|
||||
|
||||
function updateMixBadge(key, fieldName) {
|
||||
const count = document.querySelectorAll(`input[name="${fieldName}"]:checked`).length;
|
||||
const badge = document.getElementById(`badge-${key}`);
|
||||
badge.textContent = count;
|
||||
badge.className = count > 0
|
||||
? 'badge bg-primary rounded-pill ms-2'
|
||||
: 'badge bg-secondary rounded-pill ms-2';
|
||||
container.style.display = '';
|
||||
} catch (e) {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
|
||||
// --- Randomizers (global so inline onclick can call them) ---
|
||||
function randomizeCategory(fieldName, catKey) {
|
||||
const cbs = Array.from(document.querySelectorAll(`input[name="${fieldName}"]`));
|
||||
cbs.forEach(cb => cb.checked = false);
|
||||
if (cbs.length) cbs[Math.floor(Math.random() * cbs.length)].checked = true;
|
||||
updateMixBadge(catKey, fieldName);
|
||||
}
|
||||
|
||||
function applyLuckyDip() {
|
||||
const charOpts = Array.from(document.getElementById('character').options).filter(o => o.value);
|
||||
if (charOpts.length)
|
||||
document.getElementById('character').value = charOpts[Math.floor(Math.random() * charOpts.length)].value;
|
||||
|
||||
const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
||||
if (ckptOpts.length)
|
||||
document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
|
||||
|
||||
const presets = Array.from(document.querySelectorAll('.preset-btn'));
|
||||
if (presets.length) presets[Math.floor(Math.random() * presets.length)].click();
|
||||
|
||||
[['action_slugs', 'action'], ['outfit_slugs', 'outfit'], ['scene_slugs', 'scene'],
|
||||
['style_slugs', 'style'], ['detailer_slugs', 'detailer']].forEach(([field, key]) => {
|
||||
randomizeCategory(field, key);
|
||||
});
|
||||
|
||||
clearTagWidgets('prompt-tags', 'prompt-preview');
|
||||
clearTagWidgets('face-tags', 'face-preview');
|
||||
clearTagWidgets('hand-tags', 'hand-preview');
|
||||
document.getElementById('preview-status').textContent = '';
|
||||
}
|
||||
|
||||
// --- Resolution presets ---
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
document.querySelectorAll('.res-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.getElementById('res-width').value = btn.dataset.w;
|
||||
document.getElementById('res-height').value = btn.dataset.h;
|
||||
document.querySelectorAll('.preset-btn').forEach(b => {
|
||||
document.querySelectorAll('.res-btn').forEach(b => {
|
||||
b.classList.remove('btn-secondary');
|
||||
b.classList.add('btn-outline-secondary');
|
||||
});
|
||||
@@ -283,235 +176,149 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Deselect res presets when manual input changes
|
||||
['res-width', 'res-height'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
document.querySelectorAll('.preset-btn').forEach(b => {
|
||||
document.querySelectorAll('.res-btn').forEach(b => {
|
||||
b.classList.remove('btn-secondary');
|
||||
b.classList.add('btn-outline-secondary');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Tag Widget System ---
|
||||
function populateTagWidgets(containerId, textareaId, promptStr) {
|
||||
const container = document.getElementById(containerId);
|
||||
const textarea = document.getElementById(textareaId);
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!promptStr || !promptStr.trim()) {
|
||||
container.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = promptStr.split(',').map(t => t.trim()).filter(Boolean);
|
||||
tags.forEach(tag => {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'tag-widget active';
|
||||
el.textContent = tag;
|
||||
el.dataset.tag = tag;
|
||||
el.addEventListener('click', () => {
|
||||
el.classList.toggle('active');
|
||||
el.classList.toggle('inactive');
|
||||
rebuildFromTags(containerId, textareaId);
|
||||
});
|
||||
container.appendChild(el);
|
||||
});
|
||||
container.classList.remove('d-none');
|
||||
textarea.classList.add('d-none');
|
||||
}
|
||||
|
||||
function rebuildFromTags(containerId, textareaId) {
|
||||
const container = document.getElementById(containerId);
|
||||
const textarea = document.getElementById(textareaId);
|
||||
const activeTags = Array.from(container.querySelectorAll('.tag-widget.active'))
|
||||
.map(el => el.dataset.tag);
|
||||
textarea.value = activeTags.join(', ');
|
||||
}
|
||||
|
||||
function clearTagWidgets(containerId, textareaId) {
|
||||
const container = document.getElementById(containerId);
|
||||
const textarea = document.getElementById(textareaId);
|
||||
container.innerHTML = '';
|
||||
container.classList.add('d-none');
|
||||
textarea.classList.remove('d-none');
|
||||
textarea.value = '';
|
||||
}
|
||||
|
||||
// --- Prompt preview ---
|
||||
async function buildPromptPreview() {
|
||||
const charVal = document.getElementById('character').value;
|
||||
const status = document.getElementById('preview-status');
|
||||
if (!charVal) { status.textContent = 'Select a character first.'; return; }
|
||||
|
||||
status.textContent = 'Building...';
|
||||
const formData = new FormData(document.getElementById('generator-form'));
|
||||
try {
|
||||
const resp = await fetch('/generator/preview_prompt', { method: 'POST', body: formData });
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
status.textContent = 'Error: ' + data.error;
|
||||
} else {
|
||||
document.getElementById('prompt-preview').value = data.prompt;
|
||||
document.getElementById('face-preview').value = data.face || '';
|
||||
document.getElementById('hand-preview').value = data.hand || '';
|
||||
populateTagWidgets('prompt-tags', 'prompt-preview', data.prompt);
|
||||
populateTagWidgets('face-tags', 'face-preview', data.face || '');
|
||||
populateTagWidgets('hand-tags', 'hand-preview', data.hand || '');
|
||||
status.textContent = 'Click tags to toggle — Clear to reset.';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = 'Request failed.';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('build-preview-btn').addEventListener('click', buildPromptPreview);
|
||||
document.getElementById('clear-preview-btn').addEventListener('click', () => {
|
||||
clearTagWidgets('prompt-tags', 'prompt-preview');
|
||||
clearTagWidgets('face-tags', 'face-preview');
|
||||
clearTagWidgets('hand-tags', 'hand-preview');
|
||||
document.getElementById('preview-status').textContent = '';
|
||||
// --- Seed ---
|
||||
document.getElementById('seed-clear-btn').addEventListener('click', () => {
|
||||
document.getElementById('seed-input').value = '';
|
||||
});
|
||||
|
||||
// --- Main generation logic ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('generator-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressCont = document.getElementById('progress-container');
|
||||
const progressLbl = document.getElementById('progress-label');
|
||||
const generateBtn = document.getElementById('generate-btn');
|
||||
const endlessBtn = document.getElementById('endless-btn');
|
||||
const stopBtn = document.getElementById('stop-btn');
|
||||
const numInput = document.getElementById('num-images');
|
||||
const resultImg = document.getElementById('result-img');
|
||||
const placeholder = document.getElementById('placeholder-text');
|
||||
const resultFooter = document.getElementById('result-footer');
|
||||
|
||||
let currentJobId = null;
|
||||
let stopRequested = false;
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') progressLbl.textContent = 'Generating…';
|
||||
else progressLbl.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function setGeneratingState(active) {
|
||||
generateBtn.disabled = active;
|
||||
endlessBtn.disabled = active;
|
||||
stopBtn.classList.toggle('d-none', !active);
|
||||
if (!active) progressCont.classList.add('d-none');
|
||||
}
|
||||
|
||||
async function runOne(label) {
|
||||
if (document.getElementById('lucky-dip').checked) applyLuckyDip();
|
||||
|
||||
progressCont.classList.remove('d-none');
|
||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressLbl.textContent = label;
|
||||
|
||||
const fd = new FormData(form);
|
||||
|
||||
const resp = await fetch(form.action, {
|
||||
method: 'POST', body: fd,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
currentJobId = data.job_id;
|
||||
progressLbl.textContent = 'Queued…';
|
||||
|
||||
const jobResult = await waitForJob(currentJobId);
|
||||
currentJobId = null;
|
||||
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
resultImg.src = jobResult.result.image_url;
|
||||
resultImg.parentElement.classList.remove('d-none');
|
||||
if (placeholder) placeholder.classList.add('d-none');
|
||||
resultFooter.classList.remove('d-none');
|
||||
}
|
||||
updateSeedFromResult(jobResult.result);
|
||||
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
|
||||
async function runLoop(endless) {
|
||||
const total = endless ? Infinity : (parseInt(numInput.value) || 1);
|
||||
stopRequested = false;
|
||||
setGeneratingState(true);
|
||||
let n = 0;
|
||||
try {
|
||||
while (!stopRequested && n < total) {
|
||||
n++;
|
||||
const lbl = endless ? `Generating #${n} (endless)...`
|
||||
: total === 1 ? 'Starting...'
|
||||
: `Generating ${n} / ${total}...`;
|
||||
await runOne(lbl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Generation failed: ' + err.message);
|
||||
} finally {
|
||||
setGeneratingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (e) => { e.preventDefault(); runLoop(false); });
|
||||
endlessBtn.addEventListener('click', () => runLoop(true));
|
||||
stopBtn.addEventListener('click', () => {
|
||||
stopRequested = true;
|
||||
progressLbl.textContent = 'Stopping after current image...';
|
||||
});
|
||||
|
||||
document.getElementById('random-char-btn').addEventListener('click', () => {
|
||||
const opts = Array.from(document.getElementById('character').options).filter(o => o.value);
|
||||
// --- Random buttons ---
|
||||
document.getElementById('random-preset-btn').addEventListener('click', () => {
|
||||
const opts = Array.from(document.getElementById('preset-select').options).filter(o => o.value);
|
||||
if (opts.length) {
|
||||
document.getElementById('character').value = opts[Math.floor(Math.random() * opts.length)].value;
|
||||
buildPromptPreview();
|
||||
const pick = opts[Math.floor(Math.random() * opts.length)];
|
||||
document.getElementById('preset-select').value = pick.value;
|
||||
loadPresetInfo(pick.value);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('random-ckpt-btn').addEventListener('click', () => {
|
||||
const opts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
||||
if (opts.length)
|
||||
if (opts.length) {
|
||||
document.getElementById('checkpoint').value = opts[Math.floor(Math.random() * opts.length)].value;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('character').addEventListener('change', buildPromptPreview);
|
||||
// --- Lucky Dip ---
|
||||
function applyLuckyDip() {
|
||||
document.getElementById('random-preset-btn').click();
|
||||
const ckptOpts = Array.from(document.getElementById('checkpoint').options).filter(o => o.value);
|
||||
if (ckptOpts.length)
|
||||
document.getElementById('checkpoint').value = ckptOpts[Math.floor(Math.random() * ckptOpts.length)].value;
|
||||
const resBtns = Array.from(document.querySelectorAll('.res-btn'));
|
||||
if (resBtns.length) resBtns[Math.floor(Math.random() * resBtns.length)].click();
|
||||
}
|
||||
|
||||
// Pre-populate from gallery URL params (?action=slug, ?outfit=slug, etc.)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const catMap = {
|
||||
action: { field: 'action_slugs', catKey: 'action' },
|
||||
outfit: { field: 'outfit_slugs', catKey: 'outfit' },
|
||||
scene: { field: 'scene_slugs', catKey: 'scene' },
|
||||
style: { field: 'style_slugs', catKey: 'style' },
|
||||
detailer: { field: 'detailer_slugs', catKey: 'detailer' },
|
||||
};
|
||||
let preselected = false;
|
||||
for (const [param, { field, catKey }] of Object.entries(catMap)) {
|
||||
const val = urlParams.get(param);
|
||||
if (!val) continue;
|
||||
const cb = document.querySelector(`input[name="${field}"][value="${CSS.escape(val)}"]`);
|
||||
if (cb) {
|
||||
cb.checked = true;
|
||||
updateMixBadge(catKey, field);
|
||||
// Expand the accordion panel
|
||||
const panel = document.getElementById(`mix-${catKey}`);
|
||||
if (panel) new bootstrap.Collapse(panel, { toggle: false }).show();
|
||||
preselected = true;
|
||||
// --- Preset select change ---
|
||||
document.getElementById('preset-select').addEventListener('change', (e) => {
|
||||
loadPresetInfo(e.target.value);
|
||||
});
|
||||
|
||||
// --- Generation loop ---
|
||||
let stopRequested = false;
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
while (true) {
|
||||
const res = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await res.json();
|
||||
if (data.status === 'done') return data;
|
||||
if (data.status === 'failed') throw new Error(data.error || 'Generation failed');
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
}
|
||||
if (preselected) buildPromptPreview();
|
||||
|
||||
function setGeneratingState(active) {
|
||||
document.getElementById('generate-btn').disabled = active;
|
||||
document.getElementById('endless-btn').classList.toggle('d-none', active);
|
||||
document.getElementById('stop-btn').classList.toggle('d-none', !active);
|
||||
document.getElementById('progress-container').classList.toggle('d-none', !active);
|
||||
}
|
||||
|
||||
function updateSeedFromResult(result) {
|
||||
if (result && result.result && result.result.seed != null) {
|
||||
document.getElementById('seed-input').value = result.result.seed;
|
||||
}
|
||||
}
|
||||
|
||||
async function runOne(label) {
|
||||
if (document.getElementById('lucky-dip').checked) applyLuckyDip();
|
||||
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = label;
|
||||
document.getElementById('progress-label').textContent = label;
|
||||
|
||||
const form = document.getElementById('generator-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const res = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
const result = await waitForJob(data.job_id);
|
||||
|
||||
if (result.result && result.result.image_url) {
|
||||
const img = document.getElementById('result-img');
|
||||
img.src = result.result.image_url + '?t=' + Date.now();
|
||||
img.parentElement.classList.remove('d-none');
|
||||
document.getElementById('placeholder-text')?.classList.add('d-none');
|
||||
document.getElementById('result-footer').classList.remove('d-none');
|
||||
}
|
||||
|
||||
updateSeedFromResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runLoop(endless) {
|
||||
stopRequested = false;
|
||||
setGeneratingState(true);
|
||||
|
||||
const total = endless ? Infinity : parseInt(document.getElementById('num-images').value) || 1;
|
||||
let i = 0;
|
||||
|
||||
try {
|
||||
while (i < total && !stopRequested) {
|
||||
i++;
|
||||
const label = endless
|
||||
? `Generating (endless) #${i}...`
|
||||
: total > 1
|
||||
? `Generating ${i} / ${total}...`
|
||||
: 'Generating...';
|
||||
await runOne(label);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Generation error: ' + e.message);
|
||||
} finally {
|
||||
setGeneratingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event listeners ---
|
||||
document.getElementById('generator-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
runLoop(false);
|
||||
});
|
||||
|
||||
document.getElementById('endless-btn').addEventListener('click', () => runLoop(true));
|
||||
document.getElementById('stop-btn').addEventListener('click', () => { stopRequested = true; });
|
||||
|
||||
// --- Init: load preset info if pre-selected ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetSelect = document.getElementById('preset-select');
|
||||
if (presetSelect.value) loadPresetInfo(presetSelect.value);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Character Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for characters without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all characters" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<form action="{{ url_for('rescan') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan character files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
||||
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||
{{ library_toolbar(
|
||||
title="Character",
|
||||
category="characters",
|
||||
create_url=url_for('create_character'),
|
||||
create_label="Character",
|
||||
has_batch_gen=true,
|
||||
has_regen_all=true,
|
||||
has_lora_create=false,
|
||||
has_tags=true,
|
||||
regen_tags_category="characters",
|
||||
rescan_url=url_for('rescan'),
|
||||
get_missing_url="/get_missing_characters",
|
||||
clear_covers_url="/clear_all_covers",
|
||||
generate_url_pattern="/character/{slug}/generate"
|
||||
) }}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||
</div>
|
||||
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||
{% for char in characters %}
|
||||
@@ -33,7 +51,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ char.name }}</h5>
|
||||
<h5 class="card-title text-center">
|
||||
{% if char.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ char.name }}
|
||||
{% if char.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
|
||||
</h5>
|
||||
<p class="card-text small text-center text-muted">
|
||||
{% set ns = namespace(parts=[]) %}
|
||||
{% for section_key in ['identity', 'defaults'] %}
|
||||
@@ -58,12 +79,14 @@
|
||||
{{ ns.parts | join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
{% if char.data.lora.lora_name %}
|
||||
<div class="card-footer d-flex justify-content-between align-items-center p-1">
|
||||
{% if char.data.lora and char.data.lora.lora_name %}
|
||||
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||
<div class="card-footer text-center p-1">
|
||||
<small class="text-muted" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||
<small class="text-muted text-truncate" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||
{% else %}<span></span>{% endif %}
|
||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||
data-category="characters" data-slug="{{ char.slug }}" data-name="{{ char.name | e }}">🗑</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -71,97 +94,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
||||
else nodeStatus.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
const response = await fetch('/get_missing_characters');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert("No characters missing cover images.");
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs upfront so the page can be navigated away from
|
||||
const jobs = [];
|
||||
for (const char of missing) {
|
||||
try {
|
||||
const genResp = await fetch(`/character/${char.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ action: 'replace' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item: char, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${char.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all jobs concurrently; update UI as each finishes
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} images queued.`);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_characters');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) {
|
||||
alert("No characters missing cover images.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} characters?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm("This will unassign ALL current cover images and generate new ones for every character. Existing files will be kept on disk. Proceed?")) return;
|
||||
|
||||
const clearResp = await fetch('/clear_all_covers', { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
// Update UI to show "No Image" for all
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,8 +31,13 @@
|
||||
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
|
||||
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
|
||||
<div class="vr mx-1 d-none d-lg-block"></div>
|
||||
<!-- Search -->
|
||||
<form action="/search" method="get" class="d-flex" style="max-width:180px;">
|
||||
<input type="text" name="q" class="form-control form-control-sm bg-dark text-light border-secondary" placeholder="Search..." aria-label="Search">
|
||||
</form>
|
||||
<div class="vr mx-1 d-none d-lg-block"></div>
|
||||
<!-- Queue indicator -->
|
||||
<button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Generation Queue">
|
||||
<button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Job Queue">
|
||||
<span class="queue-icon">⏳</span>
|
||||
<span id="queue-count-badge" class="queue-badge d-none">0</span>
|
||||
</button>
|
||||
@@ -93,13 +98,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generation Queue Modal -->
|
||||
<!-- Job Queue Modal -->
|
||||
<div class="modal fade" id="queueModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
Generation Queue
|
||||
Job Queue
|
||||
<span id="queue-modal-count" class="badge bg-secondary ms-2">0</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
@@ -232,6 +237,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateTags(category, slug) {
|
||||
const btn = document.getElementById('regenerate-tags-btn');
|
||||
if (!btn) return;
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating…';
|
||||
fetch(`/api/${category}/${slug}/regenerate_tags`, {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
|
||||
.then(({ok, data}) => {
|
||||
if (ok && data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origText;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert('Regeneration failed: ' + err);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origText;
|
||||
});
|
||||
}
|
||||
|
||||
function initJsonEditor(saveUrl) {
|
||||
const jsonModal = document.getElementById('jsonEditorModal');
|
||||
if (!jsonModal) return;
|
||||
@@ -533,7 +565,7 @@
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// ---- Generation Queue UI ----
|
||||
// ---- Job Queue UI ----
|
||||
(function() {
|
||||
const badge = document.getElementById('queue-count-badge');
|
||||
const modalCount = document.getElementById('queue-modal-count');
|
||||
@@ -575,7 +607,7 @@
|
||||
queueBtn.title = `${pendingJobs.length} job(s) queued`;
|
||||
} else {
|
||||
queueBtn.classList.remove('queue-btn-generating');
|
||||
queueBtn.title = 'Generation Queue';
|
||||
queueBtn.title = 'Job Queue';
|
||||
}
|
||||
|
||||
// Update modal count
|
||||
@@ -616,6 +648,15 @@
|
||||
statusDot.className = `queue-status-dot queue-status-${job.status}`;
|
||||
li.appendChild(statusDot);
|
||||
|
||||
// Job type badge
|
||||
if (job.job_type === 'llm') {
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = 'badge bg-info';
|
||||
typeBadge.textContent = 'LLM';
|
||||
typeBadge.style.fontSize = '0.6rem';
|
||||
li.appendChild(typeBadge);
|
||||
}
|
||||
|
||||
// Label
|
||||
const label = document.createElement('span');
|
||||
label.className = 'flex-grow-1 small';
|
||||
|
||||
@@ -15,20 +15,38 @@
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ form_data.get('name', '') }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="character_id" class="form-label">Linked Character</label>
|
||||
<select class="form-select" id="character_id" name="character_id">
|
||||
<option value="">— None —</option>
|
||||
{% for char in characters %}
|
||||
<option value="{{ char.character_id }}">{{ char.name }}</option>
|
||||
<option value="{{ char.character_id }}" {{ 'selected' if form_data.get('character_id') == char.character_id }}>{{ char.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags">
|
||||
{% set tags = form_data.get('tags', {}) if form_data.get('tags') is mapping else {} %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="tag_origin_series" class="form-label">Origin Series</label>
|
||||
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="tag_origin_type" class="form-label">Origin Type</label>
|
||||
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
|
||||
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
|
||||
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label"> </label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw">
|
||||
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,18 +61,18 @@
|
||||
<select class="form-select" id="lora_lora_name" name="lora_lora_name">
|
||||
<option value="">None</option>
|
||||
{% for lora in loras %}
|
||||
<option value="{{ lora }}">{{ lora }}</option>
|
||||
<option value="{{ lora }}" {{ 'selected' if form_data.get('lora_lora_name') == lora }}>{{ lora }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="lora_lora_weight" class="form-label">Weight</label>
|
||||
<input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight" value="0.8">
|
||||
<input type="number" step="0.01" class="form-control" id="lora_lora_weight" name="lora_lora_weight" value="{{ form_data.get('lora_lora_weight', 0.8) }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="lora_lora_triggers" class="form-label">Triggers</label>
|
||||
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers">
|
||||
<input type="text" class="form-control" id="lora_lora_triggers" name="lora_lora_triggers" value="{{ form_data.get('lora_lora_triggers', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,11 +83,11 @@
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="positive" class="form-label">Positive</label>
|
||||
<textarea class="form-control" id="positive" name="positive" rows="3"></textarea>
|
||||
<textarea class="form-control" id="positive" name="positive" rows="3">{{ form_data.get('positive', '') }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="negative" class="form-label">Negative</label>
|
||||
<textarea class="form-control" id="negative" name="negative" rows="2"></textarea>
|
||||
<textarea class="form-control" id="negative" name="negative" rows="2">{{ form_data.get('negative', '') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,33 +138,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set tags = look.data.tags if look.data.tags is mapping else {} %}
|
||||
{% if tags %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
<span>Tags</span>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
||||
{% if preferences is not none %}
|
||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
||||
{% elif look.default_fields is not none %}
|
||||
{% if 'special::tags' in look.default_fields %}checked{% endif %}
|
||||
{% endif %}>
|
||||
<label class="form-check-label text-white small {% if look.default_fields is not none and 'special::tags' in look.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||
<div class="card-body">
|
||||
{% for tag in look.data.tags %}
|
||||
<span class="badge bg-secondary">{{ tag }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No tags</span>
|
||||
{% endfor %}
|
||||
{% if tags.origin_series %}<span class="badge bg-info">{{ tags.origin_series }}</span>{% endif %}
|
||||
{% if tags.origin_type %}<span class="badge bg-primary">{{ tags.origin_type }}</span>{% endif %}
|
||||
{% if look.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||
{% if look.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ look.name }}</h1>
|
||||
<h1 class="mb-0">
|
||||
{{ look.name }}
|
||||
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/look/{{ look.slug }}/favourite" title="Toggle favourite">
|
||||
<span style="font-size:1.2rem;">{% if look.is_favourite %}★{% 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 %}
|
||||
<small class="text-muted">
|
||||
Linked to:
|
||||
@@ -178,6 +175,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('looks', '{{ look.slug }}')">Regenerate Tags</button>
|
||||
<button type="button" class="btn btn-accent" data-bs-toggle="modal" data-bs-target="#generateCharModal">
|
||||
<i class="bi bi-person-plus"></i> Generate Character
|
||||
</button>
|
||||
@@ -281,6 +279,16 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
const data = await resp.json();
|
||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
|
||||
@@ -39,9 +39,27 @@
|
||||
</div>
|
||||
<div class="form-text">Associates this look with multiple characters for generation and LoRA suggestions.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ look.data.tags | join(', ') }}">
|
||||
{% set tags = look.data.tags if look.data.tags is mapping else {} %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="tag_origin_series" class="form-label">Origin Series</label>
|
||||
<input type="text" class="form-control" id="tag_origin_series" name="tag_origin_series" value="{{ tags.origin_series or '' }}" placeholder="e.g. Fire Emblem, Mario, Original">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="tag_origin_type" class="form-label">Origin Type</label>
|
||||
<select class="form-select" id="tag_origin_type" name="tag_origin_type">
|
||||
{% for opt in ['', 'Anime', 'Video Game', 'Cartoon', 'Movie', 'Comic', 'Original'] %}
|
||||
<option value="{{ opt }}" {% if tags.origin_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label"> </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>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Looks Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for looks without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all looks" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new look entries from all LoRA files in the Looks folder"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_looks_from_loras') }}" method="post" class="d-contents">
|
||||
<input type="hidden" name="overwrite" value="true">
|
||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all look metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL look LoRAs, consuming significant API credits and overwriting ALL existing look metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<a href="{{ url_for('create_look') }}" class="btn btn-sm btn-success">Create New Look</a>
|
||||
<form action="{{ url_for('rescan_looks') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan look files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
||||
</form>
|
||||
</div>
|
||||
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||
{{ library_toolbar(
|
||||
title="Look",
|
||||
category="looks",
|
||||
create_url=url_for('create_look'),
|
||||
create_label="Look",
|
||||
has_batch_gen=true,
|
||||
has_regen_all=true,
|
||||
has_lora_create=true,
|
||||
bulk_create_url=url_for('bulk_create_looks_from_loras'),
|
||||
has_tags=true,
|
||||
regen_tags_category="looks",
|
||||
rescan_url=url_for('rescan_looks'),
|
||||
get_missing_url="/get_missing_looks",
|
||||
clear_covers_url="/clear_all_look_covers",
|
||||
generate_url_pattern="/look/{slug}/generate"
|
||||
) }}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||
</div>
|
||||
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||
{% for look in looks %}
|
||||
@@ -43,7 +55,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ look.name }}</h5>
|
||||
<h5 class="card-title text-center">{% if look.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ look.name }}{% if look.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||
{% if look.character_id %}
|
||||
<p class="card-text small text-center text-info">{{ look.character_id.replace('_', ' ').title() }}</p>
|
||||
{% endif %}
|
||||
@@ -86,133 +98,11 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle highlight parameter
|
||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||
if (highlightSlug) {
|
||||
const card = document.getElementById(`card-${highlightSlug}`);
|
||||
if (card) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const itemNameText = document.getElementById('current-item-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
let currentJobId = null;
|
||||
let queuePollInterval = null;
|
||||
|
||||
async function updateCurrentJobLabel() {
|
||||
try {
|
||||
const resp = await fetch('/api/queue');
|
||||
const data = await resp.json();
|
||||
const processingJob = data.jobs.find(j => j.status === 'processing');
|
||||
if (processingJob) {
|
||||
itemNameText.textContent = `Processing: ${processingJob.label}`;
|
||||
} else {
|
||||
itemNameText.textContent = '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch queue:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
||||
else nodeStatus.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
const response = await fetch('/get_missing_looks');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert("No looks missing cover images.");
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs upfront
|
||||
|
||||
const jobs = [];
|
||||
for (const item of missing) {
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/look/${item.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ action: 'replace' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${item.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all concurrently
|
||||
// Start polling queue for current job label
|
||||
queuePollInterval = setInterval(updateCurrentJobLabel, 1000);
|
||||
updateCurrentJobLabel(); // Initial update
|
||||
|
||||
let completed = 0;
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
// Stop polling queue
|
||||
if (queuePollInterval) {
|
||||
clearInterval(queuePollInterval);
|
||||
queuePollInterval = null;
|
||||
}
|
||||
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} look images processed.`);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_looks');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) { alert("No looks missing cover images."); return; }
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} looks?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm("This will unassign ALL current look cover images and generate new ones. Proceed?")) return;
|
||||
const clearResp = await fetch('/clear_all_look_covers', { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,23 +10,23 @@
|
||||
<form action="{{ url_for('create_outfit') }}" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Outfit Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. French Maid" required>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. French Maid" value="{{ form_data.get('name', '') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="filename" class="form-label">Filename (Slug) <small class="text-muted">- optional, auto-generated if empty</small></label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. french_maid_01">
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. french_maid_01" value="{{ form_data.get('filename', '') }}">
|
||||
<div class="form-text">Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
|
||||
<label class="form-check-label" for="use_llm">Use AI to generate profile from description</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="prompt-group">
|
||||
<label for="prompt" class="form-label">Description / Concept</label>
|
||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the outfit's style, components, colors, and any special features. The AI will generate the full outfit profile based on this."></textarea>
|
||||
<textarea class="form-control" id="prompt" name="prompt" rows="5" placeholder="Describe the outfit's style, components, colors, and any special features. The AI will generate the full outfit profile based on this.">{{ form_data.get('prompt', '') }}</textarea>
|
||||
<div class="form-text">Required when AI generation is enabled.</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -100,33 +100,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set tags = outfit.data.tags if outfit.data.tags is mapping else {} %}
|
||||
{% if tags %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
<span>Tags</span>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_field" value="special::tags" id="includeTags" form="generate-form"
|
||||
{% if preferences is not none %}
|
||||
{% if 'special::tags' in preferences %}checked{% endif %}
|
||||
{% elif outfit.default_fields is not none %}
|
||||
{% if 'special::tags' in outfit.default_fields %}checked{% endif %}
|
||||
{% endif %}>
|
||||
<label class="form-check-label text-white small {% if outfit.default_fields is not none and 'special::tags' in outfit.default_fields %}text-accent{% endif %}" for="includeTags">Include</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||
<div class="card-body">
|
||||
{% for tag in outfit.data.tags %}
|
||||
<span class="badge bg-secondary">{{ tag }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No tags</span>
|
||||
{% endfor %}
|
||||
{% if tags.outfit_type %}<span class="badge bg-info">{{ tags.outfit_type }}</span>{% endif %}
|
||||
{% if outfit.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||
{% if outfit.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ outfit.name }}</h1>
|
||||
<h1 class="mb-0">
|
||||
{{ outfit.name }}
|
||||
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/outfit/{{ outfit.slug }}/favourite" title="Toggle favourite">
|
||||
<span style="font-size:1.2rem;">{% if outfit.is_favourite %}★{% 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>
|
||||
<form action="{{ url_for('clone_outfit', slug=outfit.slug) }}" method="post" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-link text-decoration-none">Clone Outfit</button>
|
||||
@@ -134,6 +130,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('outfits', '{{ outfit.slug }}')">Regenerate Tags</button>
|
||||
<a href="{{ url_for('transfer_resource', category='outfits', slug=outfit.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||
<a href="{{ url_for('outfits_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
@@ -280,6 +277,16 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
const data = await resp.json();
|
||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
|
||||
@@ -22,9 +22,23 @@
|
||||
<label for="outfit_id" class="form-label">Outfit ID</label>
|
||||
<input type="text" class="form-control" id="outfit_id" name="outfit_id" value="{{ outfit.outfit_id }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags (comma separated)</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags" value="{{ outfit.data.tags | join(', ') }}">
|
||||
{% set tags = outfit.data.tags if outfit.data.tags is mapping else {} %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="tag_outfit_type" class="form-label">Outfit Type</label>
|
||||
<select class="form-select" id="tag_outfit_type" name="tag_outfit_type">
|
||||
{% for opt in ['', 'Formal', 'Casual', 'Swimsuit', 'Lingerie', 'Underwear', 'Nude', 'Cosplay', 'Uniform', 'Fantasy', 'Armor', 'Traditional'] %}
|
||||
<option value="{{ opt }}" {% if tags.outfit_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label"> </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>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Outfit Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for outfits without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all outfits" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new outfit entries from all LoRA files in the Clothing folder"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_outfits_from_loras') }}" method="post" class="d-contents">
|
||||
<input type="hidden" name="overwrite" value="true">
|
||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all outfit metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL outfit LoRAs, consuming significant API credits and overwriting ALL existing outfit metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<a href="{{ url_for('create_outfit') }}" class="btn btn-sm btn-success">Create New Outfit</a>
|
||||
<form action="{{ url_for('rescan_outfits') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan outfit files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
||||
</form>
|
||||
</div>
|
||||
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||
{{ library_toolbar(
|
||||
title="Outfit",
|
||||
category="outfits",
|
||||
create_url=url_for('create_outfit'),
|
||||
create_label="Outfit",
|
||||
has_batch_gen=true,
|
||||
has_regen_all=true,
|
||||
has_lora_create=true,
|
||||
bulk_create_url=url_for('bulk_create_outfits_from_loras'),
|
||||
has_tags=true,
|
||||
regen_tags_category="outfits",
|
||||
rescan_url=url_for('rescan_outfits'),
|
||||
get_missing_url="/get_missing_outfits",
|
||||
clear_covers_url="/clear_all_outfit_covers",
|
||||
generate_url_pattern="/outfit/{slug}/generate"
|
||||
) }}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||
</div>
|
||||
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||
{% for outfit in outfits %}
|
||||
@@ -43,7 +55,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ outfit.name }}</h5>
|
||||
<h5 class="card-title text-center">{% if outfit.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ outfit.name }}{% if outfit.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||
<p class="card-text small text-center text-muted">
|
||||
{% set ns = namespace(parts=[]) %}
|
||||
{% if outfit.data.wardrobe is mapping %}
|
||||
@@ -83,111 +95,12 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle highlight parameter
|
||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||
if (highlightSlug) {
|
||||
const card = document.getElementById(`card-${highlightSlug}`);
|
||||
if (card) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const itemNameText = document.getElementById('current-item-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
||||
else nodeStatus.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
const response = await fetch('/get_missing_outfits');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert("No outfits missing cover images.");
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs upfront
|
||||
|
||||
const jobs = [];
|
||||
for (const item of missing) {
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/outfit/${item.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${item.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all concurrently
|
||||
let currentItem = '';
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
currentItem = item.name;
|
||||
itemNameText.textContent = `Processing: ${currentItem}`;
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} outfit images processed.`);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_outfits');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) {
|
||||
alert("No outfits missing cover images.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} outfits?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm("This will unassign ALL current outfit cover images and generate new ones. Proceed?")) return;
|
||||
|
||||
const clearResp = await fetch('/clear_all_outfit_covers', { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
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">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Preset Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual">
|
||||
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Beach Day Casual" value="{{ form_data.get('name', '') }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" checked>
|
||||
<input class="form-check-input" type="checkbox" id="use_llm" name="use_llm" {{ 'checked' if form_data.get('use_llm', True) }}>
|
||||
<label class="form-check-label" for="use_llm">Use AI to build preset from description</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
<textarea class="form-control" id="description" name="description" rows="5"
|
||||
placeholder="Describe the kind of generations you want this preset to produce. Include the type of character, setting, mood, outfit style, etc.
|
||||
|
||||
Example: A random character in a cheerful beach scene, wearing a swimsuit, with a random action pose. Always include identity and wardrobe fields. Randomise headwear and accessories. No style LoRA needed."></textarea>
|
||||
Example: A random character in a cheerful beach scene, wearing a swimsuit, with a random action pose. Always include identity and wardrobe fields. Randomise headwear and accessories. No style LoRA needed.">{{ form_data.get('description', '') }}</textarea>
|
||||
<div class="form-text">The AI will set entity IDs (specific, random, or none) and field toggles (on/off/random) based on your description.</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -153,6 +153,20 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr class="my-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<small class="fw-semibold">Suppress Wardrobe</small>
|
||||
<div class="form-text mt-0">Strip all clothing/wardrobe prompts from generation</div>
|
||||
</div>
|
||||
{% set sw = act.get('suppress_wardrobe') %}
|
||||
<select class="form-select form-select-sm" name="act_suppress_wardrobe" style="width:auto">
|
||||
<option value="default" {% if sw is none %}selected{% endif %}>Action default</option>
|
||||
<option value="true" {% if sw == true %}selected{% endif %}>Always</option>
|
||||
<option value="false" {% if sw == false %}selected{% endif %}>Never</option>
|
||||
<option value="random" {% if sw == 'random' %}selected{% endif %}>Random</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<form action="{{ url_for('create_scene') }}" method="post">
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-bold">Scene Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Luxury Bedroom" required>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Luxury Bedroom" value="{{ form_data.get('name', '') }}" required>
|
||||
<div class="form-text">The display name for the scene gallery.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="filename" class="form-label fw-bold">Scene ID / Filename <small class="text-muted">(Optional)</small></label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. luxury_bedroom">
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. luxury_bedroom" value="{{ form_data.get('filename', '') }}">
|
||||
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -116,7 +116,13 @@
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ scene.name }}</h1>
|
||||
<h1 class="mb-0">
|
||||
{{ scene.name }}
|
||||
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/scene/{{ scene.slug }}/favourite" title="Toggle favourite">
|
||||
<span style="font-size:1.2rem;">{% if scene.is_favourite %}★{% 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">
|
||||
<a href="{{ url_for('edit_scene', slug=scene.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Scene</a>
|
||||
<span class="text-muted">|</span>
|
||||
@@ -127,6 +133,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('scenes', '{{ scene.slug }}')">Regenerate Tags</button>
|
||||
<a href="{{ url_for('transfer_resource', category='scenes', slug=scene.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||
<a href="{{ url_for('scenes_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
@@ -266,6 +273,16 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
const data = await resp.json();
|
||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
|
||||
@@ -106,11 +106,23 @@
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light"><strong>Tags</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags"
|
||||
value="{{ scene.data.tags | join(', ') if scene.data.tags else '' }}">
|
||||
<div class="form-text">Comma-separated tags appended to every generation.</div>
|
||||
{% set tags = scene.data.tags if scene.data.tags is mapping else {} %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="tag_scene_type" class="form-label">Scene Type</label>
|
||||
<select class="form-select" id="tag_scene_type" name="tag_scene_type">
|
||||
{% for opt in ['', 'Indoor', 'Outdoor', 'Fantasy', 'Urban', 'Nature', 'Abstract'] %}
|
||||
<option value="{{ opt }}" {% if tags.scene_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label"> </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>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
{% extends "layout.html" %}
|
||||
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Scene Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for scenes without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all scenes" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new scene entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_scenes_from_loras') }}" method="post" class="d-contents">
|
||||
<input type="hidden" name="overwrite" value="true">
|
||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all scene metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL scene LoRAs, consuming significant API credits and overwriting ALL existing scene metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<a href="{{ url_for('create_scene') }}" class="btn btn-sm btn-success">Create New Scene</a>
|
||||
<form action="{{ url_for('rescan_scenes') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan scene files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
||||
</form>
|
||||
</div>
|
||||
{{ library_toolbar(
|
||||
title="Scene",
|
||||
category="scenes",
|
||||
create_url=url_for('create_scene'),
|
||||
create_label="Scene",
|
||||
has_batch_gen=true,
|
||||
has_regen_all=true,
|
||||
has_lora_create=true,
|
||||
bulk_create_url=url_for('bulk_create_scenes_from_loras'),
|
||||
has_tags=true,
|
||||
regen_tags_category="scenes",
|
||||
rescan_url=url_for('rescan_scenes'),
|
||||
get_missing_url="/get_missing_scenes",
|
||||
clear_covers_url="/clear_all_scene_covers",
|
||||
generate_url_pattern="/scene/{slug}/generate"
|
||||
) }}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||
</div>
|
||||
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||
{% for scene in scenes %}
|
||||
@@ -40,7 +52,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ scene.name }}</h5>
|
||||
<h5 class="card-title text-center">{% if scene.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ scene.name }}{% if scene.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||
<p class="card-text small text-center text-muted">
|
||||
{% set ns = namespace(parts=[]) %}
|
||||
{% if scene.data.scene is mapping %}
|
||||
@@ -80,110 +92,11 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle highlight parameter
|
||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||
if (highlightSlug) {
|
||||
const card = document.getElementById(`card-${highlightSlug}`);
|
||||
if (card) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const sceneNameText = document.getElementById('current-scene-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
||||
else nodeStatus.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
const response = await fetch('/get_missing_scenes');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert("No scenes missing cover images.");
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs upfront
|
||||
|
||||
const jobs = [];
|
||||
for (const scene of missing) {
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/scene/${scene.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ action: 'replace', character_slug: '' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item: scene, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${scene.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all concurrently
|
||||
let currentItem = '';
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
currentItem = item.name;
|
||||
itemNameText.textContent = `Processing: ${currentItem}`;
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
batchBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} scene images processed.`);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_scenes');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) {
|
||||
alert("No scenes missing cover images.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} scenes?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm("This will unassign ALL current scene cover images and generate new ones. Proceed?")) return;
|
||||
|
||||
const clearResp = await fetch('/clear_all_scene_covers', { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
126
templates/search.html
Normal file
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>
|
||||
|
||||
<!-- Tag Management -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-dark text-white">Tag Management</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Migrate Tags</h6>
|
||||
<p class="text-muted small">Convert old list-format tags to new structured dict format across all resources.</p>
|
||||
<button class="btn btn-warning" id="migrate-tags-btn" onclick="migrateTags()">Migrate Tags to New Format</button>
|
||||
<span id="migrate-tags-status" class="ms-2"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Bulk Regenerate Tags</h6>
|
||||
<p class="text-muted small">Use LLM to regenerate structured tags for all resources. This will overwrite existing tags.</p>
|
||||
<button class="btn btn-danger" id="bulk-regen-btn" onclick="bulkRegenerateTags()">Regenerate All Tags (LLM)</button>
|
||||
<div id="bulk-regen-progress" class="mt-2" style="display: none;">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<small class="text-muted" id="bulk-regen-status"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -339,5 +365,59 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function migrateTags() {
|
||||
const btn = document.getElementById('migrate-tags-btn');
|
||||
const status = document.getElementById('migrate-tags-status');
|
||||
if (!confirm('Convert all old list-format tags to new dict format?')) return;
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Migrating...';
|
||||
try {
|
||||
const resp = await fetch('/admin/migrate_tags', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
status.textContent = data.success ? `Done! Migrated ${data.migrated} resources.` : `Error: ${data.error}`;
|
||||
} catch (err) {
|
||||
status.textContent = 'Failed: ' + err.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkRegenerateTags() {
|
||||
if (!confirm('Regenerate tags for ALL resources using the LLM? This may take a while and will overwrite existing tags.')) return;
|
||||
const btn = document.getElementById('bulk-regen-btn');
|
||||
const progress = document.getElementById('bulk-regen-progress');
|
||||
const bar = progress.querySelector('.progress-bar');
|
||||
const status = document.getElementById('bulk-regen-status');
|
||||
btn.disabled = true;
|
||||
progress.style.display = 'block';
|
||||
|
||||
const categories = ['characters', 'outfits', 'actions', 'styles', 'scenes', 'detailers', 'looks'];
|
||||
// Fetch all slugs per category
|
||||
let allItems = [];
|
||||
for (const cat of categories) {
|
||||
try {
|
||||
const resp = await fetch(`/get_missing_${cat}`);
|
||||
// This endpoint returns items missing covers, but we need ALL items.
|
||||
// Instead, we'll use a simpler approach: fetch the index page data
|
||||
} catch (e) {}
|
||||
}
|
||||
// Use a simpler approach: call regenerate for each category via a bulk endpoint
|
||||
status.textContent = 'Queuing regeneration for all resources...';
|
||||
try {
|
||||
const resp = await fetch('/admin/bulk_regenerate_tags', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
bar.style.width = '100%';
|
||||
status.textContent = `Queued ${data.total} resources for regeneration. Check console for progress.`;
|
||||
} else {
|
||||
status.textContent = `Error: ${data.error}`;
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = 'Failed: ' + err.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<form action="{{ url_for('create_style') }}" method="post">
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-bold">Style Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Sabu Style" required>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Sabu Style" value="{{ form_data.get('name', '') }}" required>
|
||||
<div class="form-text">The display name for the style gallery.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="filename" class="form-label fw-bold">Style ID / Filename <small class="text-muted">(Optional)</small></label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. sabu_01">
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="e.g. sabu_01" value="{{ form_data.get('filename', '') }}">
|
||||
<div class="form-text">Used for the JSON file and URL. Auto-generated from name if empty.</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -116,7 +116,13 @@
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">{{ style.name }}</h1>
|
||||
<h1 class="mb-0">
|
||||
{{ style.name }}
|
||||
<button class="btn btn-sm btn-link text-decoration-none fav-toggle-btn" data-url="/style/{{ style.slug }}/favourite" title="Toggle favourite">
|
||||
<span style="font-size:1.2rem;">{% if style.is_favourite %}★{% 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">
|
||||
<a href="{{ url_for('edit_style', slug=style.slug) }}" class="btn btn-sm btn-link text-decoration-none ps-0">Edit Style</a>
|
||||
<span class="text-muted">|</span>
|
||||
@@ -127,6 +133,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#jsonEditorModal">Edit JSON</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="regenerate-tags-btn" onclick="regenerateTags('styles', '{{ style.slug }}')">Regenerate Tags</button>
|
||||
<a href="{{ url_for('transfer_resource', category='styles', slug=style.slug) }}" class="btn btn-outline-primary">Transfer</a>
|
||||
<a href="{{ url_for('styles_index') }}" class="btn btn-outline-secondary">Back to Library</a>
|
||||
</div>
|
||||
@@ -258,6 +265,16 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Favourite toggle
|
||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||
const data = await resp.json();
|
||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.getElementById('generate-form');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
|
||||
@@ -81,6 +81,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light"><strong>Tags</strong></div>
|
||||
<div class="card-body">
|
||||
{% set tags = style.data.tags if style.data.tags is mapping else {} %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="tag_style_type" class="form-label">Style Type</label>
|
||||
<select class="form-select" id="tag_style_type" name="tag_style_type">
|
||||
{% for opt in ['', 'Anime', 'Realistic', 'Western', 'Artistic', 'Sketch', 'Watercolor', 'Digital', 'Pixel Art'] %}
|
||||
<option value="{{ opt }}" {% if tags.style_type == opt %}selected{% endif %}>{{ opt or '— Select —' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label"> </label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="tag_nsfw" name="tag_nsfw" {% if style.is_nsfw %}checked{% endif %}>
|
||||
<label class="form-check-label" for="tag_nsfw">NSFW</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('style_detail', slug=style.slug) }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
{% extends "layout.html" %}
|
||||
{% from "partials/library_toolbar.html" import library_toolbar %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Style Library</h2>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button id="batch-generate-btn" class="btn btn-sm btn-outline-success btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Generate cover images for styles without one" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<button id="regenerate-all-btn" class="btn btn-sm btn-outline-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Regenerate cover images for all styles" data-requires="comfyui"><img src="{{ url_for('static', filename='icons/new-cover-batch.png') }}"></button>
|
||||
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Create new style entries from all LoRA files"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<form action="{{ url_for('bulk_create_styles_from_loras') }}" method="post" class="d-contents">
|
||||
<input type="hidden" name="overwrite" value="true">
|
||||
<button type="submit" class="btn btn-sm btn-danger btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Overwrite all style metadata from LoRA files (uses API credits)" onclick="return confirm('WARNING: This will re-run LLM generation for ALL style LoRAs, consuming significant API credits and overwriting ALL existing style metadata. Are you absolutely sure?')"><img src="{{ url_for('static', filename='icons/new-file.png') }}"></button>
|
||||
</form>
|
||||
<a href="{{ url_for('create_style') }}" class="btn btn-sm btn-success">Create New Style</a>
|
||||
<form action="{{ url_for('rescan_styles') }}" method="post" class="d-contents">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-icon" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Rescan style files from disk"><img src="{{ url_for('static', filename='icons/refresh.png') }}"></button>
|
||||
</form>
|
||||
</div>
|
||||
{{ library_toolbar(
|
||||
title="Style",
|
||||
category="styles",
|
||||
create_url=url_for('create_style'),
|
||||
create_label="Style",
|
||||
has_batch_gen=true,
|
||||
has_regen_all=true,
|
||||
has_lora_create=true,
|
||||
bulk_create_url=url_for('bulk_create_styles_from_loras'),
|
||||
has_tags=true,
|
||||
regen_tags_category="styles",
|
||||
rescan_url=url_for('rescan_styles'),
|
||||
get_missing_url="/get_missing_styles",
|
||||
clear_covers_url="/clear_all_style_covers",
|
||||
generate_url_pattern="/style/{slug}/generate"
|
||||
) }}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="favFilter">★ Favourites</label>
|
||||
</div>
|
||||
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
|
||||
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
|
||||
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
|
||||
{% for style in styles %}
|
||||
@@ -40,7 +52,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ style.name }}</h5>
|
||||
<h5 class="card-title text-center">{% if style.is_favourite %}<span class="text-warning">★</span> {% endif %}{{ style.name }}{% if style.is_nsfw %} <span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}</h5>
|
||||
<p class="card-text small text-center text-muted">
|
||||
{% set ns = namespace(parts=[]) %}
|
||||
{% if style.data.style is mapping %}
|
||||
@@ -80,111 +92,11 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle highlight parameter
|
||||
const highlightSlug = new URLSearchParams(window.location.search).get('highlight');
|
||||
if (highlightSlug) {
|
||||
const card = document.getElementById(`card-${highlightSlug}`);
|
||||
if (card) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const styleNameText = document.getElementById('current-style-name');
|
||||
const stepProgressText = document.getElementById('current-step-progress');
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
||||
else if (data.status === 'processing') nodeStatus.textContent = 'Generating…';
|
||||
else nodeStatus.textContent = 'Queued…';
|
||||
} catch (err) {}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
const response = await fetch('/get_missing_styles');
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert("No styles missing cover images.");
|
||||
return;
|
||||
}
|
||||
|
||||
batchBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs upfront
|
||||
|
||||
const jobs = [];
|
||||
for (const style of missing) {
|
||||
|
||||
try {
|
||||
const genResp = await fetch(`/style/${style.slug}/generate`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ action: 'replace', character_slug: '__random__' }),
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item: style, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${style.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all concurrently
|
||||
let currentItem = '';
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
currentItem = item.name;
|
||||
styleNameText.textContent = `Processing: ${currentItem}`;
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
batchBtn.disabled = false;
|
||||
regenAllBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} style images processed.`);
|
||||
}
|
||||
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
const response = await fetch('/get_missing_styles');
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) {
|
||||
alert("No styles missing cover images.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} styles?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!confirm("This will unassign ALL current style cover images and generate new ones. Proceed?")) return;
|
||||
|
||||
const clearResp = await fetch('/clear_all_style_covers', { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user