diff --git a/CLAUDE.md b/CLAUDE.md index 711ea76..35203ba 100644 --- a/CLAUDE.md +++ b/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 -- **`_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. +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 //` — gallery +- `GET //` — library with favourite/NSFW filter controls - `GET //` — detail + generation UI - `POST ///generate` — queue generation; returns `{"job_id": ...}` - `POST ///replace_cover_from_preview` - `GET/POST ///edit` - `POST ///upload` - `POST ///save_defaults` +- `POST ///favourite` — toggle `is_favourite` (AJAX) - `POST ///clone` — duplicate entry - `POST ///save_json` — save raw JSON (from modal editor) - `POST //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///regenerate_tags` — single entity tag regeneration via LLM queue +- `POST /admin/bulk_regenerate_tags/` — 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 ///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. diff --git a/app.py b/app.py index 703c453..00a7497 100644 --- a/app.py +++ b/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()) diff --git a/data/prompts/action_system.txt b/data/prompts/action_system.txt index 7c2e159..738d23f 100644 --- a/data/prompts/action_system.txt +++ b/data/prompts/action_system.txt @@ -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). diff --git a/data/prompts/character_system.txt b/data/prompts/character_system.txt index a6e167b..f8db0ea 100644 --- a/data/prompts/character_system.txt +++ b/data/prompts/character_system.txt @@ -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. diff --git a/data/prompts/checkpoint_system.txt b/data/prompts/checkpoint_system.txt index aea5bff..ae555e9 100644 --- a/data/prompts/checkpoint_system.txt +++ b/data/prompts/checkpoint_system.txt @@ -16,9 +16,16 @@ 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. - "base_negative": Comma-separated tags to suppress unwanted artifacts. Look for recommended negative prompt tags in the HTML. diff --git a/data/prompts/detailer_system.txt b/data/prompts/detailer_system.txt index 17f795c..4871ca6 100644 --- a/data/prompts/detailer_system.txt +++ b/data/prompts/detailer_system.txt @@ -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). diff --git a/data/prompts/look_system.txt b/data/prompts/look_system.txt index d68d38f..d21baf3 100644 --- a/data/prompts/look_system.txt +++ b/data/prompts/look_system.txt @@ -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). diff --git a/data/prompts/outfit_system.txt b/data/prompts/outfit_system.txt index de249d6..5982b1f 100644 --- a/data/prompts/outfit_system.txt +++ b/data/prompts/outfit_system.txt @@ -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. diff --git a/data/prompts/preset_system.txt b/data/prompts/preset_system.txt index 08a8632..b949d34 100644 --- a/data/prompts/preset_system.txt +++ b/data/prompts/preset_system.txt @@ -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. diff --git a/data/prompts/regenerate_tags_system.txt b/data/prompts/regenerate_tags_system.txt new file mode 100644 index 0000000..95bf8f8 --- /dev/null +++ b/data/prompts/regenerate_tags_system.txt @@ -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.). diff --git a/data/prompts/scene_system.txt b/data/prompts/scene_system.txt index 497d006..c2eca52 100644 --- a/data/prompts/scene_system.txt +++ b/data/prompts/scene_system.txt @@ -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). diff --git a/data/prompts/style_system.txt b/data/prompts/style_system.txt index 5501d6f..6656c37 100644 --- a/data/prompts/style_system.txt +++ b/data/prompts/style_system.txt @@ -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). diff --git a/models.py b/models.py index ff67a24..365d0b5 100644 --- a/models.py +++ b/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'' @@ -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'' @@ -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'', '', clean_html, flags=re.DOTALL) - clean_html = re.sub(r']*>', '', 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}") + json_filename = f"{action_id}.json" + json_path = os.path.join(app.config['ACTIONS_DIR'], json_filename) + is_existing = os.path.exists(json_path) + if is_existing and not overwrite: + skipped += 1 + continue + + # 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: - 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###" + with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf: + html_raw = hf.read() + clean_html = re.sub(r']*>.*?', '', html_raw, flags=re.DOTALL) + clean_html = re.sub(r']*>.*?', '', clean_html, flags=re.DOTALL) + clean_html = re.sub(r']*>', '', clean_html) + clean_html = re.sub(r'<[^>]+>', ' ', clean_html) + html_content = ' '.join(clean_html.split()) + except Exception: + pass - llm_response = call_llm(prompt, system_prompt) + 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###" - # 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}") + # Enqueue a sync task to run after all creates + if job_ids: + def sync_task(job): + sync_actions() + job['result'] = {'synced': True} + _enqueue_task("Sync actions DB", sync_task) - if created_count > 0 or overwritten_count > 0: - 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.') + 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//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//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)) diff --git a/routes/characters.py b/routes/characters.py index 646e423..191da5b 100644 --- a/routes/characters.py +++ b/routes/characters.py @@ -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//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)) diff --git a/routes/checkpoints.py b/routes/checkpoints.py index f7633a6..6fafd8e 100644 --- a/routes/checkpoints.py +++ b/routes/checkpoints.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, 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}") - 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###" - ) - 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 + # 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) + ckpt_data['checkpoint_path'] = checkpoint_path + ckpt_data['checkpoint_name'] = filename + for key, val in defaults.items(): + if key not in ckpt_data or ckpt_data[key] is None: + ckpt_data[key] = val + except Exception as e: + logger.error("LLM error for %s: %s. Using defaults.", filename, e) + ckpt_data = defaults + + with open(json_path, 'w') as f: + json.dump(ckpt_data, f, indent=2) + + 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: - ckpt_data = defaults + # 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: + logger.error("Error saving JSON for %s: %s", filename, e) - try: - with open(json_path, 'w') as f: - json.dump(ckpt_data, f, indent=2) - if is_existing: - overwritten_count += 1 - else: - created_count += 1 - except Exception as e: - print(f"Error saving JSON for {filename}: {e}") + needs_sync = len(job_ids) > 0 or written_directly > 0 - if created_count > 0 or overwritten_count > 0: - 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) - else: - flash(f'No checkpoints created or overwritten. {skipped_count} existing entries found.') + if needs_sync: + if job_ids: + # Sync after all LLM tasks complete + def sync_task(job): + sync_checkpoints() + job['result'] = {'synced': True} + _enqueue_task("Sync checkpoints DB", sync_task) + else: + # 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//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)) diff --git a/routes/detailers.py b/routes/detailers.py index 4d1980d..8185a85 100644 --- a/routes/detailers.py +++ b/routes/detailers.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, 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,63 +367,63 @@ 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}") - 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###" + 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###" - llm_response = call_llm(prompt, system_prompt) - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - detailer_data = json.loads(clean_json) + llm_response = call_llm(prompt, system_prompt) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + detailer_data = json.loads(clean_json) - detailer_data['detailer_id'] = detailer_id - detailer_data['detailer_name'] = detailer_name + detailer_data['detailer_id'] = detailer_id + detailer_data['detailer_name'] = detailer_name - if 'lora' not in detailer_data: detailer_data['lora'] = {} - detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" + if 'lora' not in detailer_data: detailer_data['lora'] = {} + detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" - if not detailer_data['lora'].get('lora_triggers'): - detailer_data['lora']['lora_triggers'] = name_base - if detailer_data['lora'].get('lora_weight') is None: - detailer_data['lora']['lora_weight'] = 1.0 - if detailer_data['lora'].get('lora_weight_min') is None: - detailer_data['lora']['lora_weight_min'] = 0.7 - if detailer_data['lora'].get('lora_weight_max') is None: - detailer_data['lora']['lora_weight_max'] = 1.0 + if not detailer_data['lora'].get('lora_triggers'): + detailer_data['lora']['lora_triggers'] = name_base + if detailer_data['lora'].get('lora_weight') is None: + detailer_data['lora']['lora_weight'] = 1.0 + if detailer_data['lora'].get('lora_weight_min') is None: + detailer_data['lora']['lora_weight_min'] = 0.7 + if detailer_data['lora'].get('lora_weight_max') is None: + detailer_data['lora']['lora_weight_max'] = 1.0 - with open(json_path, 'w') as f: - json.dump(detailer_data, f, indent=2) + 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: - 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.') + if job_ids: + def sync_task(job): + sync_detailers() + 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//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)) diff --git a/routes/gallery.py b/routes/gallery.py index 1b05582..2e90be8 100644 --- a/routes/gallery.py +++ b/routes/gallery.py @@ -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'], diff --git a/routes/generator.py b/routes/generator.py index 1b11322..14cc920 100644 --- a/routes/generator.py +++ b/routes/generator.py @@ -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) + try: + 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) + seed_val = request.form.get('seed', '').strip() + if seed_val: + overrides['seed'] = int(seed_val) - 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 + width = request.form.get('width', '').strip() + height = request.form.get('height', '').strip() + if width and height: + overrides['width'] = int(width) + overrides['height'] = int(height) - # 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 + job = generate_from_preset(preset, overrides, save_category='generator') - # Parse optional seed - seed_val = request.form.get('seed', '').strip() - fixed_seed = int(seed_val) if seed_val else None + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} - # 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, - ) + flash("Generation queued.") + return redirect(url_for('generator', preset=preset_slug)) - print(f"Queueing generator prompt for {character.character_id}") + except Exception as 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)) - _finalize = _make_finalize('characters', character.slug) - label = f"Generator: {character.name}" - job = _enqueue_job(label, workflow, _finalize) + @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 - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} + preset = Preset.query.filter_by(slug=slug).first() + if not preset: + return {'error': 'not found'}, 404 - flash("Generation queued.") - except Exception as e: - print(f"Generator error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error: {str(e)}") + data = preset.data + info = {} - return render_template('generator.html', characters=characters, checkpoints=checkpoints, - actions=actions, outfits=outfits, scenes=scenes, - styles=styles, detailers=detailers, selected_ckpt=selected_ckpt) + # 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 - @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 + # 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 - character = Character.query.filter_by(slug=char_slug).first() - if not character: - return {'error': 'Character not found'}, 404 + # 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' - 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', '') + # 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' - 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 [] - - 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}" - - return {'prompt': combined, 'face': prompts['face'], 'hand': prompts['hand']} + return info diff --git a/routes/looks.py b/routes/looks.py index 9c5be20..e1011f3 100644 --- a/routes/looks.py +++ b/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 } - os.makedirs(app.config['LOOKS_DIR'], exist_ok=True) - with open(file_path, 'w') as f: - json.dump(data, f, indent=2) + 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) - db.session.add(new_look) - db.session.commit() + 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, + 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)) + 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,54 +567,59 @@ 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}") - 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###" + 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###" - llm_response = call_llm(prompt, system_prompt) - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - look_data = json.loads(clean_json) + llm_response = call_llm(prompt, system_prompt) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + look_data = json.loads(clean_json) - look_data['look_id'] = look_id - look_data['look_name'] = look_name + look_data['look_id'] = look_id + look_data['look_name'] = look_name - if 'lora' not in look_data: - look_data['lora'] = {} - look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" - if not look_data['lora'].get('lora_triggers'): - look_data['lora']['lora_triggers'] = name_base - if look_data['lora'].get('lora_weight') is None: - look_data['lora']['lora_weight'] = 0.8 - if look_data['lora'].get('lora_weight_min') is None: - look_data['lora']['lora_weight_min'] = 0.7 - if look_data['lora'].get('lora_weight_max') is None: - look_data['lora']['lora_weight_max'] = 1.0 + if 'lora' not in look_data: + look_data['lora'] = {} + look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" + if not look_data['lora'].get('lora_triggers'): + look_data['lora']['lora_triggers'] = name_base + if look_data['lora'].get('lora_weight') is None: + look_data['lora']['lora_weight'] = 0.8 + if look_data['lora'].get('lora_weight_min') is None: + look_data['lora']['lora_weight_min'] = 0.7 + 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) - with open(json_path, 'w') as f: - json.dump(look_data, f, indent=2) + 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 job_ids: + def sync_task(job): + sync_looks() + job['result'] = {'synced': True} + _enqueue_task("Sync looks DB", sync_task) - if created_count > 0 or overwritten_count > 0: - 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.') + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'success': True, 'queued': len(job_ids), 'skipped': skipped} + flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).') + return redirect(url_for('looks_index')) - return redirect(url_for('looks_index')) \ No newline at end of file + @app.route('/look//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)) \ No newline at end of file diff --git a/routes/outfits.py b/routes/outfits.py index 4e08b1a..9cac33a 100644 --- a/routes/outfits.py +++ b/routes/outfits.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, 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,57 +106,59 @@ def register_routes(app): clean_html = re.sub(r']*>', '', 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) - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - outfit_data = json.loads(clean_json) + 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}" - if not outfit_data['lora'].get('lora_triggers'): - outfit_data['lora']['lora_triggers'] = name_base - 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: - outfit_data['lora']['lora_weight_min'] = 0.7 - if outfit_data['lora'].get('lora_weight_max') is None: - outfit_data['lora']['lora_weight_max'] = 1.0 + if 'lora' not in outfit_data: + outfit_data['lora'] = {} + outfit_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}" + if not outfit_data['lora'].get('lora_triggers'): + 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: + outfit_data['lora']['lora_weight_min'] = 0.7 + 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: - json.dump(outfit_data, f, indent=2) + 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}") + # Enqueue a sync task to run after all creates + if job_ids: + def sync_task(job): + sync_outfits() + job['result'] = {'synced': True} + _enqueue_task("Sync outfits DB", sync_task) - if created_count > 0 or overwritten_count > 0: - 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.') + 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//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//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)) diff --git a/routes/presets.py b/routes/presets.py index 0e797bf..c8522f6 100644 --- a/routes/presets.py +++ b/routes/presets.py @@ -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(): diff --git a/routes/queue_api.py b/routes/queue_api.py index 16fdb12..2ac77a3 100644 --- a/routes/queue_api.py +++ b/routes/queue_api.py @@ -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 = [ - { - 'id': j['id'], - 'label': j['label'], - 'status': j['status'], - 'error': j['error'], - 'created_at': j['created_at'], - } - for j in _job_queue - ] + 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'], + '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//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 - try: - _job_queue.remove(job) - except ValueError: - pass # Already not in queue + for q in _ALL_QUEUES: + try: + q.remove(job) + break + except ValueError: + continue job['status'] = 'removed' return {'status': 'ok'} @@ -58,24 +68,29 @@ def register_routes(app): job['status'] = 'paused' elif job['status'] == 'paused': job['status'] = 'pending' - _queue_worker_event.set() + # 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 return {'status': 'ok', 'new_status': job['status']} @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 job in pending_jobs: - try: - _job_queue.remove(job) - job['status'] = 'removed' - removed_count += 1 - except ValueError: - pass + for q in _ALL_QUEUES: + pending_jobs = [j for j in q if j['status'] == 'pending'] + for job in pending_jobs: + try: + q.remove(job) + job['status'] = 'removed' + removed_count += 1 + except ValueError: + pass logger.info("Cleared %d pending jobs from queue", removed_count) return {'status': 'ok', 'removed_count': removed_count} @@ -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'] diff --git a/routes/regenerate.py b/routes/regenerate.py new file mode 100644 index 0000000..15f264e --- /dev/null +++ b/routes/regenerate.py @@ -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///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/', 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} diff --git a/routes/scenes.py b/routes/scenes.py index 795319f..69be138 100644 --- a/routes/scenes.py +++ b/routes/scenes.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, 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,74 +374,69 @@ 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']*>.*?', '', html_raw, flags=re.DOTALL) clean_html = re.sub(r']*>.*?', '', clean_html, flags=re.DOTALL) clean_html = re.sub(r']*>', '', 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}") - 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###" + 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) + llm_response = call_llm(prompt, system_prompt) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + scene_data = json.loads(clean_json) - # Clean response - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - scene_data = json.loads(clean_json) + scene_data['scene_id'] = scene_id + scene_data['scene_name'] = scene_name - # Enforce system values while preserving LLM-extracted metadata - scene_data['scene_id'] = scene_id - scene_data['scene_name'] = scene_name + if 'lora' not in scene_data: scene_data['lora'] = {} + scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" - if 'lora' not in scene_data: scene_data['lora'] = {} - scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" + if not scene_data['lora'].get('lora_triggers'): + scene_data['lora']['lora_triggers'] = name_base + if scene_data['lora'].get('lora_weight') is None: + scene_data['lora']['lora_weight'] = 1.0 + if scene_data['lora'].get('lora_weight_min') is None: + scene_data['lora']['lora_weight_min'] = 0.7 + if scene_data['lora'].get('lora_weight_max') is None: + scene_data['lora']['lora_weight_max'] = 1.0 - if not scene_data['lora'].get('lora_triggers'): - scene_data['lora']['lora_triggers'] = name_base - if scene_data['lora'].get('lora_weight') is None: - scene_data['lora']['lora_weight'] = 1.0 - if scene_data['lora'].get('lora_weight_min') is None: - scene_data['lora']['lora_weight_min'] = 0.7 - if scene_data['lora'].get('lora_weight_max') is None: - scene_data['lora']['lora_weight_max'] = 1.0 + with open(json_path, 'w') as f: + json.dump(scene_data, f, indent=2) - with open(json_path, 'w') as f: - json.dump(scene_data, f, indent=2) + job['result'] = {'name': scene_name, 'action': 'overwritten' if is_existing else 'created'} + return task_fn - if is_existing: - overwritten_count += 1 - else: - created_count += 1 + 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']) - # Small delay to avoid API rate limits if many files - time.sleep(0.5) - - except Exception as e: - print(f"Error creating scene for {filename}: {e}") - - if created_count > 0 or overwritten_count > 0: - 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.') + if job_ids: + def sync_task(job): + sync_scenes() + 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//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//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)) diff --git a/routes/search.py b/routes/search.py new file mode 100644 index 0000000..0ff901b --- /dev/null +++ b/routes/search.py @@ -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), + ) diff --git a/routes/strengths.py b/routes/strengths.py index b0c238a..e82d3e1 100644 --- a/routes/strengths.py +++ b/routes/strengths.py @@ -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': ''} diff --git a/routes/styles.py b/routes/styles.py index 0f7c8d2..d8ed545 100644 --- a/routes/styles.py +++ b/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,66 +358,73 @@ 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'): - 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() + job_ids = [] + skipped = 0 - json_filename = f"{style_id}.json" - json_path = os.path.join(app.config['STYLES_DIR'], json_filename) + for filename in sorted(os.listdir(styles_lora_dir)): + if not filename.endswith('.safetensors'): + continue - is_existing = os.path.exists(json_path) - if is_existing and not overwrite: - skipped_count += 1 - 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() - html_filename = f"{name_base}.html" - html_path = os.path.join(styles_lora_dir, html_filename) - 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() - clean_html = re.sub(r']*>.*?', '', html_raw, flags=re.DOTALL) - clean_html = re.sub(r']*>.*?', '', clean_html, flags=re.DOTALL) - clean_html = re.sub(r']*>', '', 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}") + json_filename = f"{style_id}.json" + json_path = os.path.join(app.config['STYLES_DIR'], json_filename) + is_existing = os.path.exists(json_path) + if is_existing and not overwrite: + skipped += 1 + continue + + # 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: - 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###" + with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf: + html_raw = hf.read() + clean_html = re.sub(r']*>.*?', '', html_raw, flags=re.DOTALL) + clean_html = re.sub(r']*>.*?', '', clean_html, flags=re.DOTALL) + clean_html = re.sub(r']*>', '', clean_html) + clean_html = re.sub(r'<[^>]+>', ' ', clean_html) + html_content = ' '.join(clean_html.split()) + except Exception: + pass - llm_response = call_llm(prompt, system_prompt) + 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, 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: - 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.') + # Enqueue a sync task to run after all creates + if job_ids: + def sync_task(job): + sync_styles() + 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//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//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)) diff --git a/services/generation.py b/services/generation.py index 590cd90..fe51f2c 100644 --- a/services/generation.py +++ b/services/generation.py @@ -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 diff --git a/services/job_queue.py b/services/job_queue.py index 0752cc5..2a593a1 100644 --- a/services/job_queue.py +++ b/services/job_queue.py @@ -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() diff --git a/services/llm.py b/services/llm.py index 7ee8fba..a269c79 100644 --- a/services/llm.py +++ b/services/llm.py @@ -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'] diff --git a/services/prompts.py b/services/prompts.py index ccc9259..6dad470 100644 --- a/services/prompts.py +++ b/services/prompts.py @@ -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 diff --git a/services/sync.py b/services/sync.py index cc352a9..a82c758 100644 --- a/services/sync.py +++ b/services/sync.py @@ -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: diff --git a/templates/actions/create.html b/templates/actions/create.html index 028947f..fa6fb87 100644 --- a/templates/actions/create.html +++ b/templates/actions/create.html @@ -10,23 +10,23 @@
- +
- +
- +
Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.
- +
- +
Required when AI generation is enabled.
diff --git a/templates/actions/detail.html b/templates/actions/detail.html index 3a9b410..10afb05 100644 --- a/templates/actions/detail.html +++ b/templates/actions/detail.html @@ -111,33 +111,29 @@ + {% set tags = action.data.tags if action.data.tags is mapping else {} %} + {% if tags %}
-
- Tags -
- - -
-
+
Tags
- {% for tag in action.data.tags %} - {{ tag }} - {% else %} - No tags - {% endfor %} + {% if tags.participants %}{{ tags.participants }}{% endif %} + {% if action.is_nsfw %}NSFW{% endif %} + {% if action.is_favourite %}★ Favourite{% endif %}
+ {% endif %}
-

{{ action.name }}

+

+ {{ action.name }} + + {% if action.is_nsfw %}NSFW{% endif %} +

Edit Profile @@ -145,6 +141,7 @@
+ Transfer Back to Library
@@ -299,6 +296,16 @@ {% block scripts %} + {% endblock %} diff --git a/templates/checkpoints/detail.html b/templates/checkpoints/detail.html index 505c74b..c8f0d88 100644 --- a/templates/checkpoints/detail.html +++ b/templates/checkpoints/detail.html @@ -106,7 +106,12 @@
-

{{ ckpt.name }}

+

+ {{ ckpt.name }} + +

@@ -232,6 +237,16 @@ {% block scripts %} + {% endblock %} diff --git a/templates/create.html b/templates/create.html index 99989cc..b1cd0d0 100644 --- a/templates/create.html +++ b/templates/create.html @@ -10,26 +10,32 @@
- +
- +
Used for the JSON file and URL. No spaces or special characters. Auto-generated from name if left empty.
- +
- +
Required when AI generation is enabled.
+
+ + +
Fandom wiki URL or other character page. The AI will use this as reference for accurate appearance details.
+
+
The AI will generate a complete character profile based on your description.
@@ -51,19 +57,22 @@ + {% endblock %} diff --git a/templates/edit.html b/templates/edit.html index e1fb8b7..03c530a 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -18,9 +18,27 @@
-
- - + {% set tags = character.data.tags if character.data.tags is mapping else {} %} +
+
+ + +
+
+ + +
+
+ +
+ + +
+
@@ -121,7 +139,7 @@
- {% 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 #} diff --git a/templates/gallery.html b/templates/gallery.html index 851d6d9..2bfcc69 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -57,23 +57,50 @@
+ +
+ +
+ + +
+
+ + +
+ + +
+
{% if category != 'all' %} {{ category | capitalize }} - × + × {% endif %} {% if slug %} {{ slug }} - × + × + + {% endif %} + {% if xref_category and xref_slug %} + + Cross-ref: {{ xref_category | capitalize }} = {{ xref_slug }} + × {% endif %}
+ {% if xref_category %}{% endif %} + {% if xref_slug %}{% endif %}
@@ -113,10 +140,21 @@ class="badge {% if category == 'detailers' %}bg-secondary{% else %}bg-light text-dark{% endif %} text-decoration-none px-3 py-2"> Detailers - Checkpoints + | + + Presets + + + Generator +
@@ -222,6 +260,9 @@ 'styles': 'warning', 'detailers': 'secondary', 'checkpoints': 'dark', + 'looks': 'primary', + 'presets': 'purple', + 'generator': 'teal', } %} {% for img in images %} - + +
+
+
+ Suppress Wardrobe +
Strip all clothing/wardrobe prompts from generation
+
+ {% set sw = act.get('suppress_wardrobe') %} + +
diff --git a/templates/scenes/create.html b/templates/scenes/create.html index 2a86ae6..ccbcee0 100644 --- a/templates/scenes/create.html +++ b/templates/scenes/create.html @@ -10,13 +10,13 @@
- +
The display name for the scene gallery.
- +
- +
Used for the JSON file and URL. Auto-generated from name if empty.
diff --git a/templates/scenes/detail.html b/templates/scenes/detail.html index 8fa0c8b..232db09 100644 --- a/templates/scenes/detail.html +++ b/templates/scenes/detail.html @@ -116,7 +116,13 @@
-

{{ scene.name }}

+

+ {{ scene.name }} + + {% if scene.is_nsfw %}NSFW{% endif %} +

Edit Scene | @@ -127,6 +133,7 @@
+ Transfer Back to Library
@@ -266,6 +273,16 @@ {% block scripts %} + {% endblock %} diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..1bd124d --- /dev/null +++ b/templates/search.html @@ -0,0 +1,126 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Search

+
+ + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +{% if query %} +

Found {{ total_resources }} resource{{ 's' if total_resources != 1 }} and {{ total_images }} image{{ 's' if total_images != 1 }} for "{{ query }}"

+ +{% 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' +} %} + + +{% if grouped_resources %} +{% for cat_name, items in grouped_resources.items() %} +
+
{{ type_labels.get(cat_name, cat_name | capitalize) }} {{ items | length }}
+
+ {% for item in items %} +
+
+
+ {% if item.image_path %} + {{ item.name }} + {% else %} + No Image + {% endif %} +
+
+
+ {% if item.is_favourite %} {% endif %} + {{ item.name }} + {% if item.is_nsfw %}NSFW{% endif %} +
+

{{ item.match_context }}

+
+
+
+ {% endfor %} +
+
+{% endfor %} +{% endif %} + + +{% if images %} +
+
Gallery Images {{ images | length }}
+
+ {% for img in images %} +
+
+
+ {{ img.slug }} + {% if img.is_favourite %} + + {% endif %} + {% if img.is_nsfw %} + NSFW + {% endif %} +
+
+

{{ img.category }}/{{ img.slug }}

+
+
+
+ {% endfor %} +
+
+{% endif %} + +{% if not grouped_resources and not images %} +

No results found.

+{% endif %} + +{% endif %} +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index 31d96c6..28325c2 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -164,6 +164,32 @@
+ + +
+
Tag Management
+
+
+
+
Migrate Tags
+

Convert old list-format tags to new structured dict format across all resources.

+ + +
+
+
Bulk Regenerate Tags
+

Use LLM to regenerate structured tags for all resources. This will overwrite existing tags.

+ + +
+
+
+
{% 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; + } + } {% endblock %} diff --git a/templates/styles/create.html b/templates/styles/create.html index 35c3cf0..88f7795 100644 --- a/templates/styles/create.html +++ b/templates/styles/create.html @@ -10,13 +10,13 @@
- +
The display name for the style gallery.
- +
- +
Used for the JSON file and URL. Auto-generated from name if empty.
diff --git a/templates/styles/detail.html b/templates/styles/detail.html index 338e0fc..49d951a 100644 --- a/templates/styles/detail.html +++ b/templates/styles/detail.html @@ -116,7 +116,13 @@
-

{{ style.name }}

+

+ {{ style.name }} + + {% if style.is_nsfw %}NSFW{% endif %} +

Edit Style | @@ -127,6 +133,7 @@
+ Transfer Back to Library
@@ -258,6 +265,16 @@ {% block scripts %} + {% endblock %}